diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..d1dca90 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,17 @@ +name: "Composer Checks" + +on: [pull_request] +jobs: + lint: + name: Composer Checks + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Composer Checks + run: | + docker run --rm -v $PWD:/app composer:2.8 sh -c \ + "composer install --profile --ignore-platform-reqs && composer check" + diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..805aad8 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,17 @@ +name: "Linter" + +on: [pull_request] +jobs: + lint: + name: Linter + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Linter + run: | + docker run --rm -v $PWD:/app composer:2.8 sh -c \ + "composer install --profile --ignore-platform-reqs && composer lint" + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..488d148 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: "Tests" + +on: [pull_request] +jobs: + test: + name: Tests ${{ matrix.php-versions }} + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: ['8.1', '8.2', '8.3', 'nightly'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php-versions }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Compose install + run: composer install --ignore-platform-reqs + + - name: Run tests + run: composer test + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3326a3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,323 @@ +# Composer +/vendor/ +composer.phar +composer.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Runtime +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Temporary folders +tmp/ +temp/ + +# Build artifacts +build/ +dist/ + +# PHPUnit +.phpunit.result.cache + +# PHPStan +.phpstan.neon + +# PHP CS Fixer +.php_cs.cache + +# Laravel Pint +.php-cs-fixer.cache + +# PHP_CodeSniffer +.phpcs.xml + +# Psalm +psalm.xml +psalm-baseline.xml + +# PHPUnit coverage +clover.xml +coverage.xml +coverage/ + +# PHPUnit cache +.phpunit.result.cache + +# PHPStan cache +.phpstan.cache + +# PHP CS Fixer cache +.php_cs.cache + +# Laravel Pint cache +.php-cs-fixer.cache + +# PHP_CodeSniffer cache +.phpcs.cache + +# Psalm cache +.psalm/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Log files +*.log + +# Runtime files +*.pid +*.seed +*.pid.lock + +# Coverage directory +coverage/ + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Temporary folders +tmp/ +temp/ + +# Build artifacts +build/ +dist/ + +# PHPUnit +.phpunit.result.cache + +# PHPStan +.phpstan.neon + +# PHP CS Fixer +.php_cs.cache + +# Laravel Pint +.php-cs-fixer.cache + +# PHP_CodeSniffer +.phpcs.xml + +# Psalm +psalm.xml +psalm-baseline.xml + +# PHPUnit coverage +clover.xml +coverage.xml +coverage/ + +# PHPUnit cache +.phpunit.result.cache + +# PHPStan cache +.phpstan.cache + +# PHP CS Fixer cache +.php_cs.cache + +# Laravel Pint cache +.php-cs-fixer.cache + +# PHP_CodeSniffer cache +.phpcs.cache + +# Psalm cache +.psalm/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Log files +*.log + +# Runtime files +*.pid +*.seed +*.pid.lock + +# Coverage directory +coverage/ + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Temporary folders +tmp/ +temp/ + +# Build artifacts +build/ +dist/ + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..8fa1d37 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,75 @@ +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing the project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at [INSERT EMAIL ADDRESS]. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d84c753 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,181 @@ +# Contributing to Utopia Emails + +Thank you for your interest in contributing to Utopia Emails! This document provides guidelines and information for contributors. + +## Getting Started + +1. Fork the repository +2. Clone your fork: `git clone https://github.com/your-username/emails.git` +3. Create a new branch: `git checkout -b feature/your-feature-name` +4. Make your changes +5. Run tests: `composer test` +6. Run linting: `composer lint` +7. Run static analysis: `composer check` +8. Commit your changes: `git commit -m "Add your feature"` +9. Push to your fork: `git push origin feature/your-feature-name` +10. Create a Pull Request + +## Development Setup + +### Prerequisites + +- PHP 8.0 or later +- Composer + +### Installation + +```bash +git clone https://github.com/utopia-php/emails.git +cd emails +composer install +``` + +### Running Tests + +```bash +composer test +``` + +### Code Style + +We use Laravel Pint for code formatting. Run the following commands: + +```bash +# Check code style +composer lint + +# Fix code style issues +composer format +``` + +### Static Analysis + +We use PHPStan for static analysis: + +```bash +composer check +``` + +## Code Standards + +### PHP Standards + +- Follow PSR-12 coding standards +- Use type hints for all parameters and return types +- Write comprehensive PHPDoc comments +- Use meaningful variable and method names +- Keep methods small and focused +- Write unit tests for all new functionality + +### Testing Standards + +- Write unit tests for all new features +- Aim for high test coverage +- Use descriptive test method names +- Test both positive and negative cases +- Test edge cases and error conditions + +### Documentation Standards + +- Update README.md for new features +- Add PHPDoc comments for all public methods +- Include usage examples in documentation +- Keep documentation up to date with code changes + +## Pull Request Guidelines + +### Before Submitting + +1. Ensure all tests pass +2. Run code style checks and fix any issues +3. Run static analysis and fix any issues +4. Update documentation if needed +5. Add tests for new functionality + +### Pull Request Template + +When creating a pull request, please include: + +- A clear description of the changes +- Reference to any related issues +- Screenshots or examples if applicable +- Testing instructions if needed + +### Review Process + +- All pull requests require review +- Address feedback promptly +- Keep pull requests focused and small +- Rebase on main branch if needed + +## Issue Guidelines + +### Bug Reports + +When reporting bugs, please include: + +- PHP version +- Library version +- Steps to reproduce +- Expected behavior +- Actual behavior +- Error messages or logs + +### Feature Requests + +When requesting features, please include: + +- Use case description +- Proposed solution +- Alternative solutions considered +- Additional context + +## Development Workflow + +### Branch Naming + +- `feature/description` - New features +- `bugfix/description` - Bug fixes +- `hotfix/description` - Critical fixes +- `docs/description` - Documentation updates +- `refactor/description` - Code refactoring + +### Commit Messages + +Use clear, descriptive commit messages: + +- Use imperative mood ("Add feature" not "Added feature") +- Keep the first line under 50 characters +- Add more details in the body if needed +- Reference issues when applicable + +### Release Process + +1. Update version in composer.json +2. Update CHANGELOG.md +3. Create a release tag +4. Publish to Packagist + +## Community Guidelines + +- Be respectful and inclusive +- Help others learn and grow +- Provide constructive feedback +- Follow the Code of Conduct +- Be patient with newcomers + +## Getting Help + +- Check existing issues and discussions +- Ask questions in GitHub Discussions +- Join our Discord community +- Contact maintainers directly + +## License + +By contributing to Utopia Emails, you agree that your contributions will be licensed under the MIT License. + +## Thank You + +Thank you for contributing to Utopia Emails! Your contributions help make this project better for everyone. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..439eb20 --- /dev/null +++ b/README.md @@ -0,0 +1,350 @@ +# Utopia Emails + +[![Tests](https://github.com/utopia-php/emails/workflows/Tests/badge.svg)](https://github.com/utopia-php/emails/actions/workflows/test.yml) +[![Linter](https://github.com/utopia-php/emails/workflows/Linter/badge.svg)](https://github.com/utopia-php/emails/actions/workflows/linter.yml) +[![CodeQL](https://github.com/utopia-php/emails/workflows/CodeQL/badge.svg)](https://github.com/utopia-php/emails/actions/workflows/codeql-analysis.yml) +![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/emails.svg) +[![Discord](https://img.shields.io/discord/564160730845151244)](https://appwrite.io/discord) + +Utopia Emails library is a simple and lite library for parsing and validating email addresses. This library is aiming to be as simple and easy to learn and use. This library is maintained by the [Appwrite team](https://appwrite.io). + +Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) project, it is completely **dependency-free** and can be used as standalone with any other PHP project or framework. + +## Getting Started + +Install using composer: +```bash +composer require utopia-php/emails +``` + +```php +get(); // user@example.com +$email->getLocal(); // user +$email->getDomain(); // example.com +$email->getLocal(); // user +$email->getDomain(); // example.com +$email->isValid(); // true +$email->hasValidLocal(); // true +$email->hasValidDomain(); // true + +// Email classification +$email->isDisposable(); // false +$email->isFree(); // false +$email->isCorporate(); // true + +// Domain analysis +$email->getProvider(); // example.com +$email->getSubdomain(); // '' +$email->hasSubdomain(); // false + +// Email with subdomain +$email = new Email('user@mail.example.com'); + +$email->get(); // user@mail.example.com +$email->getLocal(); // user +$email->getDomain(); // mail.example.com +$email->getProvider(); // example.com +$email->getSubdomain(); // mail +$email->hasSubdomain(); // true + +// Email formatting +$email->getFormatted(Email::FORMAT_FULL); // user@mail.example.com +$email->getFormatted(Email::FORMAT_LOCAL); // user +$email->getFormatted(Email::FORMAT_DOMAIN); // mail.example.com +$email->getFormatted(Email::FORMAT_PROVIDER); // example.com +$email->getFormatted(Email::FORMAT_SUBDOMAIN); // mail + +// Email normalization +$email = new Email(' USER@EXAMPLE.COM '); +$email->get(); // user@example.com +$email->normalize(); // user@example.com + +``` + +## Library API + +### Email Class + +#### Constants + +The Email class provides the following constants for email formatting: + +- `Email::FORMAT_FULL` - Full email address (default) +- `Email::FORMAT_LOCAL` - Local part only (before @) +- `Email::FORMAT_DOMAIN` - Domain part only (after @) +- `Email::FORMAT_PROVIDER` - Provider domain (domain without subdomain) +- `Email::FORMAT_SUBDOMAIN` - Subdomain part only + +#### Methods + +* **get()** - Return full email address. +* **getLocal()** - Return local part (before @). +* **getDomain()** - Return domain part (after @). +* **getDomainOnly()** - Return email without local part (domain only). +* **isValid()** - Check if email is valid format. +* **hasValidLocal()** - Check if email has valid local part. +* **hasValidDomain()** - Check if email has valid domain part. +* **isDisposable()** - Check if email is from a disposable email service. +* **isFree()** - Check if email is from a free email service. +* **isCorporate()** - Check if email is from a corporate domain. +* **getProvider()** - Get email provider (domain without subdomain). +* **getSubdomain()** - Get email subdomain (if any). +* **hasSubdomain()** - Check if email has subdomain. +* **normalize()** - Normalize email address (remove extra spaces, convert to lowercase). +* **getFormatted(string $format)** - Get email in different formats. Use constants: `Email::FORMAT_FULL`, `Email::FORMAT_LOCAL`, `Email::FORMAT_DOMAIN`, `Email::FORMAT_PROVIDER`, `Email::FORMAT_SUBDOMAIN`. + +## Using the Validators + +```php +isValid('user@example.com'); // true +$basicValidator->isValid('invalid-email'); // false + +// Advanced email validation +$validator = new EmailAddress(); +$validator->isValid('user@example.com'); // true +$validator->isValid('invalid-email'); // false + +// Domain validation +$domainValidator = new EmailDomain(); +$domainValidator->isValid('user@example.com'); // true +$domainValidator->isValid('user@example..com'); // false + +// Local part validation +$localValidator = new EmailLocal(); +$localValidator->isValid('user@example.com'); // true +$localValidator->isValid('user..name@example.com'); // false + +// Non-disposable email validation +$notDisposableValidator = new EmailNotDisposable(); +$notDisposableValidator->isValid('user@example.com'); // true +$notDisposableValidator->isValid('user@10minutemail.com'); // false + +// Corporate email validation +$corporateValidator = new EmailCorporate(); +$corporateValidator->isValid('user@company.com'); // true +$corporateValidator->isValid('user@gmail.com'); // false + +``` + +## Library Validators API + +* **EmailBasic** - Basic email validation using PHP's filter_var function. +* **EmailAddress** - Advanced email validation with custom rules. +* **EmailDomain** - Validates that an email address has a valid domain. +* **EmailLocal** - Validates that an email address has a valid local part. +* **EmailNotDisposable** - Validates that an email address is not from a disposable email service. +* **EmailCorporate** - Validates that an email address is from a corporate domain (not free or disposable). + +## Email Classification + +The library automatically classifies emails into three categories: + +### Free Email Services +Common free email providers like Gmail, Yahoo, Hotmail, Outlook, etc. + +### Disposable Email Services +Temporary email services like 10minutemail, GuerrillaMail, Mailinator, etc. + +### Corporate Email Services +All other email addresses that are not classified as free or disposable. + +## Supported Email Formats + +The library supports various email formats including: + +- Basic: `user@example.com` +- With dots: `user.name@example.com` +- With plus: `user+tag@example.com` +- With hyphens: `user-name@example.com` +- With underscores: `user_name@example.com` +- With numbers: `user123@example123.com` +- With subdomains: `user@mail.example.com` +- With multiple subdomains: `user@mail.sub.example.com` + +## Validation Rules + +### Local Part (before @) +- Maximum 64 characters +- Can contain letters, numbers, dots, underscores, hyphens, and plus signs +- Cannot start or end with a dot +- Cannot contain consecutive dots + +### Domain Part (after @) +- Maximum 253 characters +- Must contain at least one dot +- Must have a valid TLD (at least 2 characters) +- Can contain letters, numbers, dots, and hyphens +- Cannot start or end with a dot or hyphen +- Cannot contain consecutive dots or hyphens + +## Data Management & Import System + +The library uses external data files to classify email domains as free or disposable. These files are located in the `data/` directory: + +- `data/free-domains.php` - List of known free email service providers +- `data/disposable-domains.php` - List of known disposable/temporary email services +- `data/sources.php` - Configuration for import sources +- `data/free-domains-manual.php` - Manually managed free email domains +- `data/disposable-domains-manual.php` - Manually managed disposable email domains + +### Import System + +The library includes a comprehensive import system that can automatically update domain lists from multiple sources. + +#### Quick Start + +```bash +# Install dependencies +composer install + +# Show current statistics +php import.php stats + +# Update all domains (preview only) +php import.php all + +# Update and commit changes +php import.php all --commit=true +``` + +#### Available Commands + +**Update All Domains** +```bash +# Preview changes without committing +php import.php all + +# Force update and commit changes +php import.php all --force=true --commit=true +``` + +**Update Disposable Domains Only** +```bash +# Update from all sources +php import.php disposable --commit=true + +# Update from specific source +php import.php disposable --source=martenson --commit=true + +# Force update even if no changes detected +php import.php disposable --force=true --commit=true +``` + +**Update Free Domains Only** +```bash +# Update free domains +php import.php free --commit=true + +# Update from specific source +php import.php free --source=manual --commit=true +``` + +**Show Statistics** +```bash +# Display current domain statistics +php import.php stats +``` + +#### Composer Scripts + +For convenience, you can also use composer scripts: + +```bash +# Using composer scripts +composer run import:all +composer run import:disposable +composer run import:free +composer run import:stats +``` + +#### Import Sources + +**Disposable Email Sources:** +- Manual Disposable Email Domains (configurable) +- Martenson Disposable Email Domains +- Disposable Email Domains +- Wes Bos Burner Email Providers +- 7c FakeFilter Domains +- Adam Loving Temporary Email Domains + +**Free Email Sources:** +- Manual Free Email Domains (configurable) + +#### Features + +- **Multiple Sources**: Support for 5+ disposable email domain sources +- **Manual Configuration**: Ability to manually manage both free and disposable email domains +- **Domain Validation**: Built-in domain validation using Utopia Domains +- **Statistics & Analysis**: Detailed domain statistics and TLD analysis +- **Deduplication**: Automatic removal of duplicate domains +- **Error Handling**: Robust error handling with graceful fallbacks + +#### Manual Domain Management + +You can manually edit the domain files to add or remove domains: + +**Free Email Domains** - Edit `data/free-domains-manual.php`: +```php +=8.0", + "utopia-php/framework": "0.33.*", + "utopia-php/cli": "^0.15", + "utopia-php/domains": "^0.1", + "utopia-php/fetch": "^0.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "laravel/pint": "1.25.*", + "phpstan/phpstan": "^1.10" + }, + "minimum-stability": "stable", + "config": { + "allow-plugins": { + "php-http/discovery": false, + "tbachert/spi": false + } + } +} + diff --git a/data/disposable-domains-manual.php b/data/disposable-domains-manual.php new file mode 100644 index 0000000..c148c08 --- /dev/null +++ b/data/disposable-domains-manual.php @@ -0,0 +1,17 @@ + [ + 'name' => 'Manual Disposable Email Domains', + 'url' => null, + 'configFile' => CONFIG_DIR.'/disposable-domains-manual.php', + ], + 'martenson' => [ + 'name' => 'Martenson Disposable Email Domains', + 'url' => 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf', + 'configFile' => CONFIG_DIR.'/disposable-domains-martenson.php', + ], + 'disposable' => [ + 'name' => 'Disposable Email Domains', + 'url' => 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt', + 'configFile' => CONFIG_DIR.'/disposable-domains-disposable.php', + ], + 'wesbos' => [ + 'name' => 'Wes Bos Burner Email Providers', + 'url' => 'https://raw.githubusercontent.com/wesbos/burner-email-providers/refs/heads/master/emails.txt', + 'configFile' => CONFIG_DIR.'/disposable-domains-wesbos.php', + ], + 'fakefilter' => [ + 'name' => '7c FakeFilter Domains', + 'url' => 'https://raw.githubusercontent.com/7c/fakefilter/main/txt/data.txt', + 'configFile' => CONFIG_DIR.'/disposable-domains-fakefilter.php', + ], + 'adamloving' => [ + 'name' => 'Adam Loving Temporary Email Domains', + 'url' => 'https://gist.githubusercontent.com/adamloving/4401361/raw/e81212c3caecb54b87ced6392e0a0de2b6466287/temporary-email-address-domains', + 'configFile' => CONFIG_DIR.'/disposable-domains-adamloving.php', + ], +]; + +/** + * Source configurations for free email domains + */ +const FREE_SOURCES = [ + 'manual' => [ + 'name' => 'Manual Free Email Domains', + 'url' => null, + 'configFile' => CONFIG_DIR.'/free-domains-manual.php', + ], + 'kikobeats' => [ + 'name' => 'Kikobeats Free Email Domains', + 'url' => 'https://raw.githubusercontent.com/Kikobeats/free-email-domains/master/domains.json', + 'configFile' => CONFIG_DIR.'/free-domains-kikobeats.php', + ], +]; + +/** + * Update disposable email domains from multiple sources + */ +function updateDisposableDomains(bool $commit, bool $force, string $source): void +{ + Console::title('Disposable Email Domains Update'); + Console::success('Utopia Emails disposable domains update process has started'); + + try { + $sources = $source ? [$source => DISPOSABLE_SOURCES[$source] ?? null] : DISPOSABLE_SOURCES; + $sources = array_filter($sources); // Remove null values + + if (empty($sources)) { + Console::error('No valid sources found'); + Console::exit(1); + } + + $allDomains = fetchAllSources($sources, 'disposable'); + $currentDomains = loadCurrentConfig('disposable-domains.php'); + + if (empty($allDomains)) { + Console::error('Failed to fetch disposable email domains or list is empty'); + Console::exit(1); + } + + Console::info('Fetched '.count($allDomains).' disposable email domains from all sources'); + + showDomainStatistics($allDomains); + + if (! $force && isConfigUpToDate($currentDomains, $allDomains)) { + Console::success('Disposable email domains are already up to date'); + Console::exit(0); + } + + Console::info('Changes detected:'); + Console::info('- Previous domains count: '.count($currentDomains)); + Console::info('- New domains count: '.count($allDomains)); + + if ($commit) { + saveConfig('disposable-domains.php', $allDomains, 'Disposable Email Domains'); + Console::success('Successfully updated disposable email domains configuration'); + } else { + Console::warning('Changes not yet committed to config file. Please provide --commit=true argument to commit changes.'); + Console::info('Preview of changes:'); + showPreview($currentDomains, $allDomains); + } + } catch (\Throwable $e) { + Console::error('Error updating disposable email domains: '.$e->getMessage()); + Console::exit(1); + } +} + +/** + * Update free email domains from multiple sources + */ +function updateFreeDomains(bool $commit, bool $force, string $source): void +{ + Console::title('Free Email Domains Update'); + Console::success('Utopia Emails free domains update process has started'); + + try { + $sources = $source ? [$source => FREE_SOURCES[$source] ?? null] : FREE_SOURCES; + $sources = array_filter($sources); // Remove null values + + if (empty($sources)) { + Console::error('No valid sources found'); + Console::exit(1); + } + + $allDomains = fetchAllSources($sources, 'free'); + $currentDomains = loadCurrentConfig('free-domains.php'); + + if (empty($allDomains)) { + Console::error('Failed to fetch free email domains or list is empty'); + Console::exit(1); + } + + Console::info('Fetched '.count($allDomains).' free email domains from all sources'); + + showDomainStatistics($allDomains); + + if (! $force && isConfigUpToDate($currentDomains, $allDomains)) { + Console::success('Free email domains are already up to date'); + Console::exit(0); + } + + Console::info('Changes detected:'); + Console::info('- Previous domains count: '.count($currentDomains)); + Console::info('- New domains count: '.count($allDomains)); + + if ($commit) { + saveConfig('free-domains.php', $allDomains, 'Free Email Domains'); + Console::success('Successfully updated free email domains configuration'); + } else { + Console::warning('Changes not yet committed to config file. Please provide --commit=true argument to commit changes.'); + Console::info('Preview of changes:'); + showPreview($currentDomains, $allDomains); + } + } catch (\Throwable $e) { + Console::error('Error updating free email domains: '.$e->getMessage()); + Console::exit(1); + } +} + +/** + * Update all email domains from all sources + */ +function updateAllDomains(bool $commit, bool $force): void +{ + Console::title('All Email Domains Update'); + Console::success('Utopia Emails all domains update process has started'); + + try { + // Update disposable domains + updateDisposableDomains($commit, $force, ''); + + Console::info(''); + + // Update free domains + updateFreeDomains($commit, $force, ''); + + Console::success('Successfully updated all email domains'); + } catch (\Throwable $e) { + Console::error('Error updating all email domains: '.$e->getMessage()); + Console::exit(1); + } +} + +/** + * Show statistics about current domain lists + */ +function showStats(): void +{ + Console::title('Email Domains Statistics'); + + try { + $disposableDomains = loadCurrentConfig('disposable-domains.php'); + $freeDomains = loadCurrentConfig('free-domains.php'); + + Console::info('Current Domain Statistics:'); + Console::info('├─ Disposable domains: '.count($disposableDomains)); + Console::info('└─ Free domains: '.count($freeDomains)); + + if (! empty($disposableDomains)) { + Console::info(''); + Console::info('Disposable Domains Analysis:'); + showDomainStatistics($disposableDomains); + } + + if (! empty($freeDomains)) { + Console::info(''); + Console::info('Free Domains Analysis:'); + showDomainStatistics($freeDomains); + } + } catch (\Throwable $e) { + Console::error('Error showing statistics: '.$e->getMessage()); + Console::exit(1); + } +} + +/** + * Fetch domains from all sources + */ +function fetchAllSources(array $sources, string $type): array +{ + $allDomains = []; + $totalSources = count($sources); + $processedSources = 0; + $totalFetched = 0; + + Console::info("Fetching from {$totalSources} sources..."); + + foreach ($sources as $sourceKey => $sourceConfig) { + $processedSources++; + Console::info("[{$processedSources}/{$totalSources}] Processing {$sourceConfig['name']}..."); + + try { + $domains = fetchSource($sourceKey, $sourceConfig, $type); + $totalFetched += count($domains); + + // Add domains to the collection, avoiding duplicates + foreach ($domains as $domain) { + $allDomains[$domain] = true; // Use associative array to avoid duplicates + } + + Console::info('✓ Fetched '.count($domains)." domains from {$sourceConfig['name']}"); + } catch (\Exception $e) { + Console::warning("⚠ Failed to fetch from {$sourceConfig['name']}: ".$e->getMessage()); + // Continue with other sources even if one fails + } + } + + // Convert back to indexed array and sort + $uniqueDomains = array_keys($allDomains); + sort($uniqueDomains); + + $duplicatesRemoved = $totalFetched - count($uniqueDomains); + Console::info("Total domains fetched: {$totalFetched}"); + Console::info("Duplicates removed: {$duplicatesRemoved}"); + Console::info('Total unique domains after merging all sources: '.count($uniqueDomains)); + + return $uniqueDomains; +} + +/** + * Fetch domains from a specific source + */ +function fetchSource(string $sourceKey, array $sourceConfig, string $type): array +{ + if ($type === 'disposable') { + switch ($sourceKey) { + case 'manual': + return loadManualDisposableDomains($sourceConfig); + case 'martenson': + return fetchMartensonDomains($sourceConfig); + case 'disposable': + return fetchDisposableDomains($sourceConfig); + case 'wesbos': + return fetchWesbosDomains($sourceConfig); + case 'fakefilter': + return fetchFakeFilterDomains($sourceConfig); + case 'adamloving': + return fetchAdamLovingDomains($sourceConfig); + default: + throw new \Exception("Unknown disposable source: {$sourceKey}"); + } + } elseif ($type === 'free') { + switch ($sourceKey) { + case 'manual': + return loadManualFreeDomains($sourceConfig); + case 'kikobeats': + return fetchKikobeatsDomains($sourceConfig); + default: + throw new \Exception("Unknown free source: {$sourceKey}"); + } + } + + throw new \Exception("Unknown type: {$type}"); +} + +/** + * Fetch domains from Martenson repository + */ +function fetchMartensonDomains(array $sourceConfig): array +{ + try { + $client = new \Utopia\Fetch\Client; + + $response = $client->fetch( + url: $sourceConfig['url'], + method: \Utopia\Fetch\Client::METHOD_GET + ); + + if ($response->getStatusCode() !== 200) { + throw new \Exception('HTTP '.$response->getStatusCode()); + } + + $content = $response->getBody(); + } catch (\Exception $e) { + throw new \Exception('Network error: '.$e->getMessage()); + } + + $domains = []; + $lines = explode("\n", $content); + $processed = 0; + $valid = 0; + + foreach ($lines as $line) { + $line = trim($line); + $processed++; + + if (empty($line) || str_starts_with($line, '#')) { + continue; + } + + if (isValidDomain($line)) { + $domains[] = strtolower($line); + $valid++; + } + } + + Console::info(" Processed {$processed} lines, found {$valid} valid domains"); + + return $domains; +} + +/** + * Fetch domains from Disposable repository + */ +function fetchDisposableDomains(array $sourceConfig): array +{ + try { + $client = new \Utopia\Fetch\Client; + + $response = $client->fetch( + url: $sourceConfig['url'], + method: \Utopia\Fetch\Client::METHOD_GET + ); + + if ($response->getStatusCode() !== 200) { + throw new \Exception('HTTP '.$response->getStatusCode()); + } + + $content = $response->getBody(); + } catch (\Exception $e) { + throw new \Exception('Network error: '.$e->getMessage()); + } + + $domains = []; + $processed = 0; + $valid = 0; + + $domainList = preg_split('/\s+/', trim($content)); + + foreach ($domainList as $domain) { + $domain = trim($domain); + $processed++; + + if (empty($domain)) { + continue; + } + + if (isValidDomain($domain)) { + $domains[] = strtolower($domain); + $valid++; + } + } + + Console::info(" Processed {$processed} domains, found {$valid} valid domains"); + + return $domains; +} + +/** + * Fetch domains from Wes Bos repository + */ +function fetchWesbosDomains(array $sourceConfig): array +{ + try { + $client = new \Utopia\Fetch\Client; + + $response = $client->fetch( + url: $sourceConfig['url'], + method: \Utopia\Fetch\Client::METHOD_GET + ); + + if ($response->getStatusCode() !== 200) { + throw new \Exception('HTTP '.$response->getStatusCode()); + } + + $content = $response->getBody(); + } catch (\Exception $e) { + throw new \Exception('Network error: '.$e->getMessage()); + } + + $domains = []; + $processed = 0; + $valid = 0; + + $domainList = preg_split('/\s+/', trim($content)); + + foreach ($domainList as $domain) { + $domain = trim($domain); + $processed++; + + if (empty($domain)) { + continue; + } + + if (isValidDomain($domain)) { + $domains[] = strtolower($domain); + $valid++; + } + } + + Console::info(" Processed {$processed} domains, found {$valid} valid domains"); + + return $domains; +} + +/** + * Fetch domains from FakeFilter repository + */ +function fetchFakeFilterDomains(array $sourceConfig): array +{ + try { + $client = new \Utopia\Fetch\Client; + + $response = $client->fetch( + url: $sourceConfig['url'], + method: \Utopia\Fetch\Client::METHOD_GET + ); + + if ($response->getStatusCode() !== 200) { + throw new \Exception('HTTP '.$response->getStatusCode()); + } + + $content = $response->getBody(); + } catch (\Exception $e) { + throw new \Exception('Network error: '.$e->getMessage()); + } + + $domains = []; + $lines = explode("\n", $content); + $processed = 0; + $valid = 0; + + foreach ($lines as $line) { + $line = trim($line); + $processed++; + + if (empty($line) || str_starts_with($line, '#')) { + continue; + } + + if (isValidDomain($line)) { + $domains[] = strtolower($line); + $valid++; + } + } + + Console::info(" Processed {$processed} lines, found {$valid} valid domains"); + + return $domains; +} + +/** + * Fetch domains from Adam Loving gist + */ +function fetchAdamLovingDomains(array $sourceConfig): array +{ + try { + $client = new \Utopia\Fetch\Client; + + $response = $client->fetch( + url: $sourceConfig['url'], + method: \Utopia\Fetch\Client::METHOD_GET + ); + + if ($response->getStatusCode() !== 200) { + throw new \Exception('HTTP '.$response->getStatusCode()); + } + + $content = $response->getBody(); + } catch (\Exception $e) { + throw new \Exception('Network error: '.$e->getMessage()); + } + + $domains = []; + $processed = 0; + $valid = 0; + + $domainList = preg_split('/\s+/', trim($content)); + + foreach ($domainList as $domain) { + $domain = trim($domain); + $processed++; + + if (empty($domain)) { + continue; + } + + if (isValidDomain($domain)) { + $domains[] = strtolower($domain); + $valid++; + } + } + + Console::info(" Processed {$processed} domains, found {$valid} valid domains"); + + return $domains; +} + +/** + * Load manual disposable domains + */ +function loadManualDisposableDomains(array $sourceConfig): array +{ + if (! file_exists($sourceConfig['configFile'])) { + Console::info(' Manual config file not found, creating empty list'); + + return []; + } + + $domains = include $sourceConfig['configFile']; + Console::info(' Loaded '.count($domains).' domains from manual config'); + + return $domains; +} + +/** + * Fetch domains from Kikobeats repository + */ +function fetchKikobeatsDomains(array $sourceConfig): array +{ + try { + $client = new \Utopia\Fetch\Client; + + $response = $client->fetch( + url: $sourceConfig['url'], + method: \Utopia\Fetch\Client::METHOD_GET + ); + + if ($response->getStatusCode() !== 200) { + throw new \Exception('HTTP '.$response->getStatusCode()); + } + + $content = $response->getBody(); + } catch (\Exception $e) { + throw new \Exception('Network error: '.$e->getMessage()); + } + + $domains = []; + $processed = 0; + $valid = 0; + + // Parse JSON content + $jsonData = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Exception('Invalid JSON response: '.json_last_error_msg()); + } + + if (! is_array($jsonData)) { + throw new \Exception('Expected array in JSON response'); + } + + foreach ($jsonData as $domain) { + $domain = trim($domain); + $processed++; + + if (empty($domain)) { + continue; + } + + if (isValidDomain($domain)) { + $domains[] = strtolower($domain); + $valid++; + } + } + + Console::info(" Processed {$processed} domains, found {$valid} valid domains"); + + return $domains; +} + +/** + * Load manual free domains + */ +function loadManualFreeDomains(array $sourceConfig): array +{ + if (! file_exists($sourceConfig['configFile'])) { + Console::info(' Manual config file not found, creating empty list'); + + return []; + } + + $domains = include $sourceConfig['configFile']; + Console::info(' Loaded '.count($domains).' domains from manual config'); + + return $domains; +} + +/** + * Validate if a domain is a valid email domain + */ +function isValidDomain(string $domain): bool +{ + if (empty($domain)) { + return false; + } + + try { + $domainObj = new \Utopia\Domains\Domain($domain); + + if ($domainObj->isTest()) { + return false; + } + + $name = $domainObj->getName(); + $tld = $domainObj->getTLD(); + + if (empty($name) || empty($tld)) { + return false; + } + + return true; + } catch (\Exception $e) { + return false; + } +} + +/** + * Load current configuration + */ +function loadCurrentConfig(string $filename): array +{ + $filepath = CONFIG_DIR.'/'.$filename; + + if (! file_exists($filepath)) { + return []; + } + + return include $filepath; +} + +/** + * Check if configuration is up to date + */ +function isConfigUpToDate(array $currentDomains, array $newDomains): bool +{ + return $currentDomains == $newDomains; +} + +/** + * Save configuration to file + */ +function saveConfig(string $filename, array $domains, string $description): void +{ + $configFile = CONFIG_DIR.'/'.$filename; + $lastUpdated = date('Y-m-d H:i:s'); + + // Sort domains for consistent output + sort($domains); + + $configContent = "getTLD(); + $tldStats[$tld] = ($tldStats[$tld] ?? 0) + 1; + + if ($domainObj->isKnown()) { + $knownDomains++; + if ($domainObj->isICANN()) { + $icannDomains++; + } elseif ($domainObj->isPrivate()) { + $privateDomains++; + } + } else { + $unknownDomains++; + } + } catch (\Exception $e) { + // Skip invalid domains + } + } + + arsort($tldStats); + $topTlds = array_slice($tldStats, 0, 10, true); + + Console::info('Domain Statistics:'); + Console::info('├─ Known domains: '.$knownDomains.' ('.round(($knownDomains / count($domains)) * 100, 1).'%)'); + Console::info('├─ ICANN domains: '.$icannDomains.' ('.round(($icannDomains / count($domains)) * 100, 1).'%)'); + Console::info('├─ Private domains: '.$privateDomains.' ('.round(($privateDomains / count($domains)) * 100, 1).'%)'); + Console::info('└─ Unknown domains: '.$unknownDomains.' ('.round(($unknownDomains / count($domains)) * 100, 1).'%)'); + + Console::info('Top 10 TLDs:'); + foreach ($topTlds as $tld => $count) { + Console::info(" ├─ .{$tld}: {$count} domains"); + } +} + +/** + * Show preview of changes + */ +function showPreview(array $currentDomains, array $newDomains): void +{ + $added = array_diff($newDomains, $currentDomains); + $removed = array_diff($currentDomains, $newDomains); + + if (! empty($added)) { + Console::info('Domains to be added ('.count($added).'):'); + foreach (array_slice($added, 0, 10) as $domain) { + Console::info(" ├─ + {$domain}"); + } + if (count($added) > 10) { + Console::info(' └─ ... and '.(count($added) - 10).' more'); + } + } + + if (! empty($removed)) { + Console::info('Domains to be removed ('.count($removed).'):'); + foreach (array_slice($removed, 0, 10) as $domain) { + Console::info(" ├─ - {$domain}"); + } + if (count($removed) > 10) { + Console::info(' └─ ... and '.(count($removed) - 10).' more'); + } + } +} + +// Setup CLI +$cli = new CLI; + +// Disposable domains command +$cli + ->task('disposable') + ->desc('Update disposable email domains from multiple sources') + ->param('commit', false, new Boolean(true), 'If set will commit changes to config file. Default is false.', true) + ->param('force', false, new Boolean(true), 'Force update even if no changes detected. Default is false.', true) + ->param('source', '', new Text(100), 'Specific source to update (optional). Leave empty to update all sources.', true) + ->action(function (bool $commit, bool $force, string $source) { + updateDisposableDomains($commit, $force, $source); + }); + +// Free domains command +$cli + ->task('free') + ->desc('Update free email domains from multiple sources') + ->param('commit', false, new Boolean(true), 'If set will commit changes to config file. Default is false.', true) + ->param('force', false, new Boolean(true), 'Force update even if no changes detected. Default is false.', true) + ->param('source', '', new Text(100), 'Specific source to update (optional). Leave empty to update all sources.', true) + ->action(function (bool $commit, bool $force, string $source) { + updateFreeDomains($commit, $force, $source); + }); + +// All domains command +$cli + ->task('all') + ->desc('Update both disposable and free email domains from all sources') + ->param('commit', false, new Boolean(true), 'If set will commit changes to config file. Default is false.', true) + ->param('force', false, new Boolean(true), 'Force update even if no changes detected. Default is false.', true) + ->action(function (bool $commit, bool $force) { + updateAllDomains($commit, $force); + }); + +// Stats command +$cli + ->task('stats') + ->desc('Show statistics about current domain lists') + ->action(function () { + showStats(); + }); + +// Run the CLI +$cli->run(); diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3d47d11 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 4 + paths: + - src + - tests diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..7a10c7a --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + ./tests/ + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..0fe772f --- /dev/null +++ b/pint.json @@ -0,0 +1,10 @@ +{ + "preset": "laravel", + "rules": { + "simplified_null_return": true, + "blank_line_before_statement": { + "statements": ["break", "continue", "declare", "return", "throw", "try"] + } + } +} + diff --git a/src/Emails/Canonicals/Provider.php b/src/Emails/Canonicals/Provider.php new file mode 100644 index 0000000..e220ac6 --- /dev/null +++ b/src/Emails/Canonicals/Provider.php @@ -0,0 +1,72 @@ + 0) { + return substr($local, 0, $plusPos); + } + + return $local; + } + + /** + * Remove all dots from local part + */ + protected function removeDots(string $local): string + { + return str_replace('.', '', $local); + } + + /** + * Remove all hyphens from local part + */ + protected function removeHyphens(string $local): string + { + return str_replace('-', '', $local); + } + + /** + * Convert local part to lowercase + */ + protected function toLowerCase(string $local): string + { + return strtolower($local); + } +} diff --git a/src/Emails/Canonicals/Providers/Fastmail.php b/src/Emails/Canonicals/Providers/Fastmail.php new file mode 100644 index 0000000..b77ac76 --- /dev/null +++ b/src/Emails/Canonicals/Providers/Fastmail.php @@ -0,0 +1,58 @@ +toLowerCase($local); + + // TODO: Commented out until manual confirmation of Fastmail's plus addressing and dots support + // Check if there's plus addressing + // $hasPlus = strpos($normalizedLocal, '+') !== false && strpos($normalizedLocal, '+') > 0; + + // Remove plus addressing (everything after +) + // $normalizedLocal = $this->removePlusAddressing($normalizedLocal); + + // Remove dots only if there was plus addressing (Fastmail treats dots as aliases only with plus) + // if ($hasPlus) { + // $normalizedLocal = $this->removeDots($normalizedLocal); + // } + + return [ + 'local' => $normalizedLocal, + 'domain' => self::CANONICAL_DOMAIN, + ]; + } + + public function getCanonicalDomain(): string + { + return self::CANONICAL_DOMAIN; + } + + public function getSupportedDomains(): array + { + return self::SUPPORTED_DOMAINS; + } +} diff --git a/src/Emails/Canonicals/Providers/Generic.php b/src/Emails/Canonicals/Providers/Generic.php new file mode 100644 index 0000000..58cbb0a --- /dev/null +++ b/src/Emails/Canonicals/Providers/Generic.php @@ -0,0 +1,57 @@ +toLowerCase($local); + + // TODO: Commented out until manual confirmation of generic providers' plus addressing, dots, and hyphens support + // Check if there's plus addressing + // $hasPlus = strpos($normalizedLocal, '+') !== false && strpos($normalizedLocal, '+') > 0; + + // Remove plus addressing (everything after +) + // $normalizedLocal = $this->removePlusAddressing($normalizedLocal); + + // Remove dots and hyphens only if there was plus addressing (generic providers treat these as aliases only with plus) + // if ($hasPlus) { + // $normalizedLocal = $this->removeDots($normalizedLocal); + // $normalizedLocal = $this->removeHyphens($normalizedLocal); + // } + + return [ + 'local' => $normalizedLocal, + 'domain' => $domain, + ]; + } + + public function getCanonicalDomain(): string + { + // Generic provider doesn't have a canonical domain + return ''; + } + + public function getSupportedDomains(): array + { + // Generic provider supports all domains + return []; + } +} diff --git a/src/Emails/Canonicals/Providers/Gmail.php b/src/Emails/Canonicals/Providers/Gmail.php new file mode 100644 index 0000000..bbd6d36 --- /dev/null +++ b/src/Emails/Canonicals/Providers/Gmail.php @@ -0,0 +1,52 @@ +toLowerCase($local); + + // Remove all dots from local part + $normalizedLocal = $this->removeDots($normalizedLocal); + + // Remove plus addressing (everything after +) + $normalizedLocal = $this->removePlusAddressing($normalizedLocal); + + return [ + 'local' => $normalizedLocal, + 'domain' => self::CANONICAL_DOMAIN, + ]; + } + + public function getCanonicalDomain(): string + { + return self::CANONICAL_DOMAIN; + } + + public function getSupportedDomains(): array + { + return self::SUPPORTED_DOMAINS; + } +} diff --git a/src/Emails/Canonicals/Providers/Icloud.php b/src/Emails/Canonicals/Providers/Icloud.php new file mode 100644 index 0000000..d2d9e1e --- /dev/null +++ b/src/Emails/Canonicals/Providers/Icloud.php @@ -0,0 +1,58 @@ +toLowerCase($local); + + // TODO: Commented out until manual confirmation of iCloud's plus addressing and dots support + // Check if there's plus addressing + // $hasPlus = strpos($normalizedLocal, '+') !== false && strpos($normalizedLocal, '+') > 0; + + // Remove plus addressing (everything after +) + // $normalizedLocal = $this->removePlusAddressing($normalizedLocal); + + // Remove dots only if there was plus addressing (iCloud treats dots as aliases only with plus) + // if ($hasPlus) { + // $normalizedLocal = $this->removeDots($normalizedLocal); + // } + + return [ + 'local' => $normalizedLocal, + 'domain' => self::CANONICAL_DOMAIN, + ]; + } + + public function getCanonicalDomain(): string + { + return self::CANONICAL_DOMAIN; + } + + public function getSupportedDomains(): array + { + return self::SUPPORTED_DOMAINS; + } +} diff --git a/src/Emails/Canonicals/Providers/Outlook.php b/src/Emails/Canonicals/Providers/Outlook.php new file mode 100644 index 0000000..fe57903 --- /dev/null +++ b/src/Emails/Canonicals/Providers/Outlook.php @@ -0,0 +1,53 @@ +toLowerCase($local); + + // TODO: Commented out until manual confirmation of Outlook's plus addressing support + // Remove plus addressing (everything after +) + // $normalizedLocal = $this->removePlusAddressing($normalizedLocal); + + return [ + 'local' => $normalizedLocal, + 'domain' => self::CANONICAL_DOMAIN, + ]; + } + + public function getCanonicalDomain(): string + { + return self::CANONICAL_DOMAIN; + } + + public function getSupportedDomains(): array + { + return self::SUPPORTED_DOMAINS; + } +} diff --git a/src/Emails/Canonicals/Providers/Protonmail.php b/src/Emails/Canonicals/Providers/Protonmail.php new file mode 100644 index 0000000..27bdaa6 --- /dev/null +++ b/src/Emails/Canonicals/Providers/Protonmail.php @@ -0,0 +1,58 @@ +toLowerCase($local); + + // TODO: Commented out until manual confirmation of ProtonMail's plus addressing and dots support + // Check if there's plus addressing + // $hasPlus = strpos($normalizedLocal, '+') !== false && strpos($normalizedLocal, '+') > 0; + + // Remove plus addressing (everything after +) + // $normalizedLocal = $this->removePlusAddressing($normalizedLocal); + + // Remove dots only if there was plus addressing (ProtonMail treats dots as aliases only with plus) + // if ($hasPlus) { + // $normalizedLocal = $this->removeDots($normalizedLocal); + // } + + return [ + 'local' => $normalizedLocal, + 'domain' => self::CANONICAL_DOMAIN, + ]; + } + + public function getCanonicalDomain(): string + { + return self::CANONICAL_DOMAIN; + } + + public function getSupportedDomains(): array + { + return self::SUPPORTED_DOMAINS; + } +} diff --git a/src/Emails/Canonicals/Providers/Yahoo.php b/src/Emails/Canonicals/Providers/Yahoo.php new file mode 100644 index 0000000..3075f92 --- /dev/null +++ b/src/Emails/Canonicals/Providers/Yahoo.php @@ -0,0 +1,64 @@ +toLowerCase($local); + + // TODO: Commented out until manual confirmation of Yahoo's plus addressing, dots, and hyphens support + // Check if there's plus addressing + // $hasPlus = strpos($normalizedLocal, '+') !== false && strpos($normalizedLocal, '+') > 0; + + // Remove plus addressing (everything after +) + // $normalizedLocal = $this->removePlusAddressing($normalizedLocal); + + // Remove dots only if there was plus addressing (Yahoo treats dots as aliases only with plus) + // if ($hasPlus) { + // $normalizedLocal = $this->removeDots($normalizedLocal); + // } + + // Remove hyphens (Yahoo treats hyphens as aliases) + // $normalizedLocal = $this->removeHyphens($normalizedLocal); + + return [ + 'local' => $normalizedLocal, + 'domain' => self::CANONICAL_DOMAIN, + ]; + } + + public function getCanonicalDomain(): string + { + return self::CANONICAL_DOMAIN; + } + + public function getSupportedDomains(): array + { + return self::SUPPORTED_DOMAINS; + } +} diff --git a/src/Emails/Email.php b/src/Emails/Email.php new file mode 100644 index 0000000..9b57af3 --- /dev/null +++ b/src/Emails/Email.php @@ -0,0 +1,404 @@ +email = \mb_strtolower(\trim($email)); + + if (empty($this->email)) { + throw new Exception('Email address cannot be empty'); + } + + $this->parts = \explode('@', $this->email); + + if (count($this->parts) !== 2) { + throw new Exception("'{$email}' must be a valid email address"); + } + + $this->local = $this->parts[0]; + $this->domain = $this->parts[1]; + + if (empty($this->local) || empty($this->domain)) { + throw new Exception("'{$email}' must be a valid email address"); + } + + // Initialize domain instance for domain parsing + $this->domainInstance = new Domain($this->domain); + } + + /** + * Return full email address + */ + public function get(): string + { + return $this->email; + } + + /** + * Return local part (before @) + */ + public function getLocal(): string + { + return $this->local; + } + + /** + * Return domain part (after @) + */ + public function getDomain(): string + { + return $this->domain; + } + + /** + * Check if email is valid format + */ + public function isValid(): bool + { + return filter_var($this->email, FILTER_VALIDATE_EMAIL) !== false; + } + + /** + * Check if email has valid local part + */ + public function hasValidLocal(): bool + { + // Check local part length + if (mb_strlen($this->local) > self::LOCAL_MAX_LENGTH) { + return false; + } + + // Check for valid characters in local part + if (! preg_match('/^[a-zA-Z0-9._+-]+$/', $this->local)) { + return false; + } + + // Check for consecutive dots + if (strpos($this->local, '..') !== false) { + return false; + } + + // Check if starts or ends with dot + if (str_starts_with($this->local, '.') || str_ends_with($this->local, '.')) { + return false; + } + + return true; + } + + /** + * Check if email has valid domain part + */ + public function hasValidDomain(): bool + { + // Check domain part length + if (mb_strlen($this->domain) > self::DOMAIN_MAX_LENGTH) { + return false; + } + + // Check for valid domain format using filter_var + if (! filter_var('test@'.$this->domain, FILTER_VALIDATE_EMAIL)) { + return false; + } + + // Use utopia domains to check if domain is known and valid + if (! $this->domainInstance->isKnown() && ! $this->domainInstance->isTest()) { + return false; + } + + return true; + } + + /** + * Check if email is from a disposable email service + */ + public function isDisposable(): bool + { + if (self::$disposableDomains === null) { + $data = include __DIR__.'/../../data/disposable-domains.php'; + if (! is_array($data)) { + throw new Exception('Disposable domains data file must return an array'); + } + self::$disposableDomains = $data; + } + + return in_array($this->domain, self::$disposableDomains); + } + + /** + * Check if email is from a free email service + */ + public function isFree(): bool + { + if (self::$freeDomains === null) { + $data = include __DIR__.'/../../data/free-domains.php'; + if (! is_array($data)) { + throw new Exception('Free domains data file must return an array'); + } + self::$freeDomains = $data; + } + + // If domain is both free and disposable, prioritize disposable classification + if (in_array($this->domain, self::$freeDomains) && $this->isDisposable()) { + return false; // It's disposable, not free + } + + return in_array($this->domain, self::$freeDomains); + } + + /** + * Check if email is from a corporate domain + */ + public function isCorporate(): bool + { + // If domain is both free and disposable, prioritize disposable classification + if ($this->isFree() && $this->isDisposable()) { + return false; // It's disposable, not corporate + } + + return ! $this->isFree() && ! $this->isDisposable(); + } + + /** + * Get email provider (domain without subdomain) + */ + public function getProvider(): string + { + // Use utopia domains to get the registerable domain (provider) + $registerable = $this->domainInstance->getRegisterable(); + + // If registerable domain is not available, fall back to the full domain + if (empty($registerable)) { + return $this->domain; + } + + return $registerable; + } + + /** + * Get email subdomain (if any) + */ + public function getSubdomain(): string + { + // Use utopia domains to get the subdomain + return $this->domainInstance->getSub(); + } + + /** + * Check if email has subdomain + */ + public function hasSubdomain(): bool + { + return ! empty($this->domainInstance->getSub()); + } + + /** + * Get the email address (as provided, just lowercased and trimmed) + */ + public function getAddress(): string + { + return $this->email; + } + + /** + * Get the canonical email address by removing aliases and provider-specific variations + * This method removes plus addressing, dot notation (for Gmail), and other aliasing techniques + * to return the canonical form of the email address + */ + public function getCanonical(): string + { + $provider = $this->getProviderForDomain($this->domain); + $canonical = $provider->getCanonical($this->local, $this->domain); + + return $canonical['local'].'@'.$canonical['domain']; + } + + /** + * Check if the email domain is supported for canonical form generation + */ + public function isCanonicalSupported(): bool + { + return $this->isDomainSupported($this->domain); + } + + /** + * Get the canonical domain for this email + */ + public function getCanonicalDomain(): ?string + { + $provider = $this->getProviderForDomain($this->domain); + + // Only return canonical domain if it's not the generic provider + if (! $provider instanceof Generic) { + return $provider->getCanonicalDomain(); + } + + return null; + } + + /** + * Initialize providers array + */ + protected static function initializeProviders(): void + { + if (self::$providers === null) { + self::$providers = [ + new Gmail, + new Outlook, + new Yahoo, + new Icloud, + new Protonmail, + new Fastmail, + ]; + } + } + + /** + * Get the appropriate provider for a given domain + */ + protected function getProviderForDomain(string $domain): Provider + { + self::initializeProviders(); + + foreach (self::$providers as $provider) { + if ($provider->supports($domain)) { + return $provider; + } + } + + // Return generic provider if no specific provider found + return new Generic; + } + + /** + * Check if a domain is supported by any provider + */ + protected function isDomainSupported(string $domain): bool + { + self::initializeProviders(); + + foreach (self::$providers as $provider) { + if ($provider->supports($domain)) { + return true; + } + } + + return false; + } + + /** + * Get email in different formats + */ + public function getFormatted(string $format = self::FORMAT_FULL): string + { + switch ($format) { + case self::FORMAT_LOCAL: + return $this->local; + case self::FORMAT_DOMAIN: + return $this->domain; + case self::FORMAT_PROVIDER: + return $this->getProvider(); + case self::FORMAT_SUBDOMAIN: + return $this->getSubdomain(); + case self::FORMAT_FULL: + default: + return $this->email; + } + } +} diff --git a/src/Emails/Validator/Email.php b/src/Emails/Validator/Email.php new file mode 100644 index 0000000..4bffadc --- /dev/null +++ b/src/Emails/Validator/Email.php @@ -0,0 +1,66 @@ +isValid(); + } catch (\Exception $e) { + return false; + } + } + + /** + * Is array + * + * Function will return true if object is array. + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + */ + public function getType(): string + { + return self::TYPE_STRING; + } +} diff --git a/src/Emails/Validator/EmailCorporate.php b/src/Emails/Validator/EmailCorporate.php new file mode 100644 index 0000000..44a9683 --- /dev/null +++ b/src/Emails/Validator/EmailCorporate.php @@ -0,0 +1,66 @@ +isValid() && $email->isCorporate(); + } catch (\Exception $e) { + return false; + } + } + + /** + * Is array + * + * Function will return true if object is array. + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + */ + public function getType(): string + { + return self::TYPE_STRING; + } +} diff --git a/src/Emails/Validator/EmailDomain.php b/src/Emails/Validator/EmailDomain.php new file mode 100644 index 0000000..a63be5e --- /dev/null +++ b/src/Emails/Validator/EmailDomain.php @@ -0,0 +1,66 @@ +isValid() && $email->hasValidDomain(); + } catch (\Exception $e) { + return false; + } + } + + /** + * Is array + * + * Function will return true if object is array. + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + */ + public function getType(): string + { + return self::TYPE_STRING; + } +} diff --git a/src/Emails/Validator/EmailLocal.php b/src/Emails/Validator/EmailLocal.php new file mode 100644 index 0000000..c34d490 --- /dev/null +++ b/src/Emails/Validator/EmailLocal.php @@ -0,0 +1,66 @@ +isValid() && $email->hasValidLocal(); + } catch (\Exception $e) { + return false; + } + } + + /** + * Is array + * + * Function will return true if object is array. + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + */ + public function getType(): string + { + return self::TYPE_STRING; + } +} diff --git a/src/Emails/Validator/EmailNotDisposable.php b/src/Emails/Validator/EmailNotDisposable.php new file mode 100644 index 0000000..a243589 --- /dev/null +++ b/src/Emails/Validator/EmailNotDisposable.php @@ -0,0 +1,66 @@ +isValid() && ! $email->isDisposable(); + } catch (\Exception $e) { + return false; + } + } + + /** + * Is array + * + * Function will return true if object is array. + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + */ + public function getType(): string + { + return self::TYPE_STRING; + } +} diff --git a/tests/Canonicals/Providers/FastmailTest.php b/tests/Canonicals/Providers/FastmailTest.php new file mode 100644 index 0000000..5f98348 --- /dev/null +++ b/tests/Canonicals/Providers/FastmailTest.php @@ -0,0 +1,71 @@ +provider = new Fastmail; + } + + public function test_supports(): void + { + $this->assertTrue($this->provider->supports('fastmail.com')); + $this->assertTrue($this->provider->supports('fastmail.fm')); + $this->assertFalse($this->provider->supports('gmail.com')); + $this->assertFalse($this->provider->supports('outlook.com')); + $this->assertFalse($this->provider->supports('example.com')); + } + + public function test_get_canonical(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of Fastmail's plus addressing and dots support + // ['user.name+tag', 'fastmail.com', 'username', 'fastmail.com'], + // ['user.name+spam', 'fastmail.com', 'username', 'fastmail.com'], + // ['user.name+newsletter', 'fastmail.com', 'username', 'fastmail.com'], + // ['user.name+work', 'fastmail.com', 'username', 'fastmail.com'], + // ['user.name+personal', 'fastmail.com', 'username', 'fastmail.com'], + // ['user.name+test123', 'fastmail.com', 'username', 'fastmail.com'], + // ['user.name+anything', 'fastmail.com', 'username', 'fastmail.com'], + // ['user.name+verylongtag', 'fastmail.com', 'username', 'fastmail.com'], + // ['user.name+tag.with.dots', 'fastmail.com', 'username', 'fastmail.com'], + // ['user.name+tag-with-hyphens', 'fastmail.com', 'username', 'fastmail.com'], + // ['user.name+tag_with_underscores', 'fastmail.com', 'username', 'fastmail.com'], + // ['user.name+tag123', 'fastmail.com', 'username', 'fastmail.com'], + // // Other Fastmail domain + // ['user.name+tag', 'fastmail.fm', 'username', 'fastmail.com'], + // Dots are preserved for Fastmail + ['user.name', 'fastmail.com', 'user.name', 'fastmail.com'], + ['u.s.e.r.n.a.m.e', 'fastmail.com', 'u.s.e.r.n.a.m.e', 'fastmail.com'], + // Edge cases + // ['user+', 'fastmail.com', 'user', 'fastmail.com'], + ['user.', 'fastmail.com', 'user.', 'fastmail.com'], + ['.user', 'fastmail.com', '.user', 'fastmail.com'], + ]; + + foreach ($testCases as [$inputLocal, $inputDomain, $expectedLocal, $expectedDomain]) { + $result = $this->provider->getCanonical($inputLocal, $inputDomain); + $this->assertEquals($expectedLocal, $result['local'], "Failed for local: {$inputLocal}@{$inputDomain}"); + $this->assertEquals($expectedDomain, $result['domain'], "Failed for domain: {$inputLocal}@{$inputDomain}"); + } + } + + public function test_get_canonical_domain(): void + { + $this->assertEquals('fastmail.com', $this->provider->getCanonicalDomain()); + } + + public function test_get_supported_domains(): void + { + $domains = $this->provider->getSupportedDomains(); + $expected = ['fastmail.com', 'fastmail.fm']; + $this->assertEquals($expected, $domains); + } +} diff --git a/tests/Canonicals/Providers/GenericTest.php b/tests/Canonicals/Providers/GenericTest.php new file mode 100644 index 0000000..2c82ea8 --- /dev/null +++ b/tests/Canonicals/Providers/GenericTest.php @@ -0,0 +1,84 @@ +provider = new Generic; + } + + public function test_supports(): void + { + // Generic provider supports all domains + $this->assertTrue($this->provider->supports('example.com')); + $this->assertTrue($this->provider->supports('test.org')); + $this->assertTrue($this->provider->supports('company.net')); + $this->assertTrue($this->provider->supports('business.co.uk')); + $this->assertTrue($this->provider->supports('gmail.com')); + $this->assertTrue($this->provider->supports('outlook.com')); + $this->assertTrue($this->provider->supports('any-domain.com')); + } + + public function test_get_canonical(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of generic providers' plus addressing, dots, and hyphens support + // // Other domains with plus addressing + // ['user.name+tag', 'example.com', 'username', 'example.com'], + // ['user.name+spam', 'example.com', 'username', 'example.com'], + // ['user.name+newsletter', 'example.com', 'username', 'example.com'], + // ['user.name+work', 'example.com', 'username', 'example.com'], + // ['user.name+personal', 'example.com', 'username', 'example.com'], + // ['user.name+test123', 'example.com', 'username', 'example.com'], + // ['user.name+anything', 'example.com', 'username', 'example.com'], + // ['user.name+verylongtag', 'example.com', 'username', 'example.com'], + // ['user.name+tag.with.dots', 'example.com', 'username', 'example.com'], + // ['user.name+tag-with-hyphens', 'example.com', 'username', 'example.com'], + // ['user.name+tag_with_underscores', 'example.com', 'username', 'example.com'], + // ['user.name+tag123', 'example.com', 'username', 'example.com'], + // Dots are preserved for other domains + ['user.name', 'example.com', 'user.name', 'example.com'], + ['u.s.e.r.n.a.m.e', 'example.com', 'u.s.e.r.n.a.m.e', 'example.com'], + // Hyphens are preserved for other domains + ['user-name', 'example.com', 'user-name', 'example.com'], + // ['user-name+tag', 'example.com', 'username', 'example.com'], + // Edge cases + // ['user+', 'example.com', 'user', 'example.com'], + ['user.', 'example.com', 'user.', 'example.com'], + ['.user', 'example.com', '.user', 'example.com'], + // Test with different domains + // ['user.name+tag', 'test.org', 'username', 'test.org'], + // ['user.name+tag', 'company.net', 'username', 'company.net'], + // ['user.name+tag', 'business.co.uk', 'username', 'business.co.uk'], + ['user.name', 'test.org', 'user.name', 'test.org'], + ['user.name', 'company.net', 'user.name', 'company.net'], + ['user.name', 'business.co.uk', 'user.name', 'business.co.uk'], + ]; + + foreach ($testCases as [$inputLocal, $inputDomain, $expectedLocal, $expectedDomain]) { + $result = $this->provider->getCanonical($inputLocal, $inputDomain); + $this->assertEquals($expectedLocal, $result['local'], "Failed for local: {$inputLocal}@{$inputDomain}"); + $this->assertEquals($expectedDomain, $result['domain'], "Failed for domain: {$inputLocal}@{$inputDomain}"); + } + } + + public function test_get_canonical_domain(): void + { + // Generic provider doesn't have a canonical domain + $this->assertEquals('', $this->provider->getCanonicalDomain()); + } + + public function test_get_supported_domains(): void + { + // Generic provider supports all domains + $domains = $this->provider->getSupportedDomains(); + $this->assertEquals([], $domains); + } +} diff --git a/tests/Canonicals/Providers/GmailTest.php b/tests/Canonicals/Providers/GmailTest.php new file mode 100644 index 0000000..104a21c --- /dev/null +++ b/tests/Canonicals/Providers/GmailTest.php @@ -0,0 +1,71 @@ +provider = new Gmail; + } + + public function test_supports(): void + { + $this->assertTrue($this->provider->supports('gmail.com')); + $this->assertTrue($this->provider->supports('googlemail.com')); + $this->assertFalse($this->provider->supports('outlook.com')); + $this->assertFalse($this->provider->supports('yahoo.com')); + $this->assertFalse($this->provider->supports('example.com')); + } + + public function test_get_canonical(): void + { + $testCases = [ + ['user.name', 'gmail.com', 'username', 'gmail.com'], + ['user.name+tag', 'gmail.com', 'username', 'gmail.com'], + ['user.name+spam', 'gmail.com', 'username', 'gmail.com'], + ['user.name+newsletter', 'gmail.com', 'username', 'gmail.com'], + ['user.name+work', 'gmail.com', 'username', 'gmail.com'], + ['user.name+personal', 'gmail.com', 'username', 'gmail.com'], + ['user.name+test123', 'gmail.com', 'username', 'gmail.com'], + ['user.name+anything', 'gmail.com', 'username', 'gmail.com'], + ['user.name+verylongtag', 'gmail.com', 'username', 'gmail.com'], + ['user.name+tag.with.dots', 'gmail.com', 'username', 'gmail.com'], + ['user.name+tag-with-hyphens', 'gmail.com', 'username', 'gmail.com'], + ['user.name+tag_with_underscores', 'gmail.com', 'username', 'gmail.com'], + ['user.name+tag123', 'gmail.com', 'username', 'gmail.com'], + ['u.s.e.r.n.a.m.e', 'gmail.com', 'username', 'gmail.com'], + ['u.s.e.r.n.a.m.e+tag', 'gmail.com', 'username', 'gmail.com'], + ['user+', 'gmail.com', 'user', 'gmail.com'], + ['user.', 'gmail.com', 'user', 'gmail.com'], + ['.user', 'gmail.com', 'user', 'gmail.com'], + ['user..name', 'gmail.com', 'username', 'gmail.com'], + // Googlemail domain + ['user.name+tag', 'googlemail.com', 'username', 'gmail.com'], + ['user.name+spam', 'googlemail.com', 'username', 'gmail.com'], + ['user.name', 'googlemail.com', 'username', 'gmail.com'], + ]; + + foreach ($testCases as [$inputLocal, $inputDomain, $expectedLocal, $expectedDomain]) { + $result = $this->provider->getCanonical($inputLocal, $inputDomain); + $this->assertEquals($expectedLocal, $result['local'], "Failed for local: {$inputLocal}@{$inputDomain}"); + $this->assertEquals($expectedDomain, $result['domain'], "Failed for domain: {$inputLocal}@{$inputDomain}"); + } + } + + public function test_get_canonical_domain(): void + { + $this->assertEquals('gmail.com', $this->provider->getCanonicalDomain()); + } + + public function test_get_supported_domains(): void + { + $domains = $this->provider->getSupportedDomains(); + $this->assertEquals(['gmail.com', 'googlemail.com'], $domains); + } +} diff --git a/tests/Canonicals/Providers/IcloudTest.php b/tests/Canonicals/Providers/IcloudTest.php new file mode 100644 index 0000000..1133d5d --- /dev/null +++ b/tests/Canonicals/Providers/IcloudTest.php @@ -0,0 +1,76 @@ +provider = new Icloud; + } + + public function test_supports(): void + { + $this->assertTrue($this->provider->supports('icloud.com')); + $this->assertTrue($this->provider->supports('me.com')); + $this->assertTrue($this->provider->supports('mac.com')); + $this->assertFalse($this->provider->supports('gmail.com')); + $this->assertFalse($this->provider->supports('outlook.com')); + $this->assertFalse($this->provider->supports('example.com')); + } + + public function test_get_canonical(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of iCloud's plus addressing and dots support + // ['user.name+tag', 'icloud.com', 'username', 'icloud.com'], + // ['user.name+spam', 'icloud.com', 'username', 'icloud.com'], + // ['user.name+newsletter', 'icloud.com', 'username', 'icloud.com'], + // ['user.name+work', 'icloud.com', 'username', 'icloud.com'], + // ['user.name+personal', 'icloud.com', 'username', 'icloud.com'], + // ['user.name+test123', 'icloud.com', 'username', 'icloud.com'], + // ['user.name+anything', 'icloud.com', 'username', 'icloud.com'], + // ['user.name+verylongtag', 'icloud.com', 'username', 'icloud.com'], + // ['user.name+tag.with.dots', 'icloud.com', 'username', 'icloud.com'], + // ['user.name+tag-with-hyphens', 'icloud.com', 'username', 'icloud.com'], + // ['user.name+tag_with_underscores', 'icloud.com', 'username', 'icloud.com'], + // ['user.name+tag123', 'icloud.com', 'username', 'icloud.com'], + // // Other Apple domains + // ['user.name+tag', 'me.com', 'username', 'icloud.com'], + // ['user.name+tag', 'mac.com', 'username', 'icloud.com'], + // Dots are preserved for iCloud + ['user.name', 'icloud.com', 'user.name', 'icloud.com'], + ['u.s.e.r.n.a.m.e', 'icloud.com', 'u.s.e.r.n.a.m.e', 'icloud.com'], + // Edge cases + // ['user+', 'icloud.com', 'user', 'icloud.com'], + ['user.', 'icloud.com', 'user.', 'icloud.com'], + ['.user', 'icloud.com', '.user', 'icloud.com'], + // Other Apple domains + ['user.name', 'me.com', 'user.name', 'icloud.com'], + ['user.name', 'mac.com', 'user.name', 'icloud.com'], + ]; + + foreach ($testCases as [$inputLocal, $inputDomain, $expectedLocal, $expectedDomain]) { + $result = $this->provider->getCanonical($inputLocal, $inputDomain); + $this->assertEquals($expectedLocal, $result['local'], "Failed for local: {$inputLocal}@{$inputDomain}"); + $this->assertEquals($expectedDomain, $result['domain'], "Failed for domain: {$inputLocal}@{$inputDomain}"); + } + } + + public function test_get_canonical_domain(): void + { + $this->assertEquals('icloud.com', $this->provider->getCanonicalDomain()); + } + + public function test_get_supported_domains(): void + { + $domains = $this->provider->getSupportedDomains(); + $expected = ['icloud.com', 'me.com', 'mac.com']; + $this->assertEquals($expected, $domains); + } +} diff --git a/tests/Canonicals/Providers/OutlookTest.php b/tests/Canonicals/Providers/OutlookTest.php new file mode 100644 index 0000000..2c2e00a --- /dev/null +++ b/tests/Canonicals/Providers/OutlookTest.php @@ -0,0 +1,87 @@ +provider = new Outlook; + } + + public function test_supports(): void + { + $this->assertTrue($this->provider->supports('outlook.com')); + $this->assertTrue($this->provider->supports('hotmail.com')); + $this->assertTrue($this->provider->supports('live.com')); + $this->assertTrue($this->provider->supports('outlook.co.uk')); + $this->assertTrue($this->provider->supports('hotmail.co.uk')); + $this->assertTrue($this->provider->supports('live.co.uk')); + $this->assertFalse($this->provider->supports('gmail.com')); + $this->assertFalse($this->provider->supports('yahoo.com')); + $this->assertFalse($this->provider->supports('example.com')); + } + + public function test_get_canonical(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of Outlook's plus addressing support + // ['user.name+tag', 'outlook.com', 'user.name', 'outlook.com'], + // ['user.name+spam', 'outlook.com', 'user.name', 'outlook.com'], + // ['user.name+newsletter', 'outlook.com', 'user.name', 'outlook.com'], + // ['user.name+work', 'outlook.com', 'user.name', 'outlook.com'], + // ['user.name+personal', 'outlook.com', 'user.name', 'outlook.com'], + // ['user.name+test123', 'outlook.com', 'user.name', 'outlook.com'], + // ['user.name+anything', 'outlook.com', 'user.name', 'outlook.com'], + // ['user.name+verylongtag', 'outlook.com', 'user.name', 'outlook.com'], + // ['user.name+tag.with.dots', 'outlook.com', 'user.name', 'outlook.com'], + // ['user.name+tag-with-hyphens', 'outlook.com', 'user.name', 'outlook.com'], + // ['user.name+tag_with_underscores', 'outlook.com', 'user.name', 'outlook.com'], + // ['user.name+tag123', 'outlook.com', 'user.name', 'outlook.com'], + // ['u.s.e.r.n.a.m.e+tag', 'outlook.com', 'u.s.e.r.n.a.m.e', 'outlook.com'], + // ['user+', 'outlook.com', 'user', 'outlook.com'], + // Dots are preserved for Outlook + ['u.s.e.r.n.a.m.e', 'outlook.com', 'u.s.e.r.n.a.m.e', 'outlook.com'], + ['user.', 'outlook.com', 'user.', 'outlook.com'], + ['.user', 'outlook.com', '.user', 'outlook.com'], + // Hotmail + // ['user.name+tag', 'hotmail.com', 'user.name', 'outlook.com'], + // ['user.name+spam', 'hotmail.com', 'user.name', 'outlook.com'], + ['user.name', 'hotmail.com', 'user.name', 'outlook.com'], + // Live + // ['user.name+tag', 'live.com', 'user.name', 'outlook.com'], + // ['user.name+spam', 'live.com', 'user.name', 'outlook.com'], + ['user.name', 'live.com', 'user.name', 'outlook.com'], + // UK variants + // ['user.name+tag', 'outlook.co.uk', 'user.name', 'outlook.com'], + // ['user.name+tag', 'hotmail.co.uk', 'user.name', 'outlook.com'], + // ['user.name+tag', 'live.co.uk', 'user.name', 'outlook.com'], + ['user.name', 'outlook.co.uk', 'user.name', 'outlook.com'], + ['user.name', 'hotmail.co.uk', 'user.name', 'outlook.com'], + ['user.name', 'live.co.uk', 'user.name', 'outlook.com'], + ]; + + foreach ($testCases as [$inputLocal, $inputDomain, $expectedLocal, $expectedDomain]) { + $result = $this->provider->getCanonical($inputLocal, $inputDomain); + $this->assertEquals($expectedLocal, $result['local'], "Failed for local: {$inputLocal}@{$inputDomain}"); + $this->assertEquals($expectedDomain, $result['domain'], "Failed for domain: {$inputLocal}@{$inputDomain}"); + } + } + + public function test_get_canonical_domain(): void + { + $this->assertEquals('outlook.com', $this->provider->getCanonicalDomain()); + } + + public function test_get_supported_domains(): void + { + $domains = $this->provider->getSupportedDomains(); + $expected = ['outlook.com', 'hotmail.com', 'live.com', 'outlook.co.uk', 'hotmail.co.uk', 'live.co.uk']; + $this->assertEquals($expected, $domains); + } +} diff --git a/tests/Canonicals/Providers/ProtonmailTest.php b/tests/Canonicals/Providers/ProtonmailTest.php new file mode 100644 index 0000000..8fe0af3 --- /dev/null +++ b/tests/Canonicals/Providers/ProtonmailTest.php @@ -0,0 +1,76 @@ +provider = new Protonmail; + } + + public function test_supports(): void + { + $this->assertTrue($this->provider->supports('protonmail.com')); + $this->assertTrue($this->provider->supports('proton.me')); + $this->assertTrue($this->provider->supports('pm.me')); + $this->assertFalse($this->provider->supports('gmail.com')); + $this->assertFalse($this->provider->supports('outlook.com')); + $this->assertFalse($this->provider->supports('example.com')); + } + + public function test_get_canonical(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of ProtonMail's plus addressing and dots support + // ['user.name+tag', 'protonmail.com', 'username', 'protonmail.com'], + // ['user.name+spam', 'protonmail.com', 'username', 'protonmail.com'], + // ['user.name+newsletter', 'protonmail.com', 'username', 'protonmail.com'], + // ['user.name+work', 'protonmail.com', 'username', 'protonmail.com'], + // ['user.name+personal', 'protonmail.com', 'username', 'protonmail.com'], + // ['user.name+test123', 'protonmail.com', 'username', 'protonmail.com'], + // ['user.name+anything', 'protonmail.com', 'username', 'protonmail.com'], + // ['user.name+verylongtag', 'protonmail.com', 'username', 'protonmail.com'], + // ['user.name+tag.with.dots', 'protonmail.com', 'username', 'protonmail.com'], + // ['user.name+tag-with-hyphens', 'protonmail.com', 'username', 'protonmail.com'], + // ['user.name+tag_with_underscores', 'protonmail.com', 'username', 'protonmail.com'], + // ['user.name+tag123', 'protonmail.com', 'username', 'protonmail.com'], + // // Other ProtonMail domains + // ['user.name+tag', 'proton.me', 'username', 'protonmail.com'], + // ['user.name+tag', 'pm.me', 'username', 'protonmail.com'], + // Dots are preserved for ProtonMail + ['user.name', 'protonmail.com', 'user.name', 'protonmail.com'], + ['u.s.e.r.n.a.m.e', 'protonmail.com', 'u.s.e.r.n.a.m.e', 'protonmail.com'], + // Edge cases + // ['user+', 'protonmail.com', 'user', 'protonmail.com'], + ['user.', 'protonmail.com', 'user.', 'protonmail.com'], + ['.user', 'protonmail.com', '.user', 'protonmail.com'], + // Other ProtonMail domains + ['user.name', 'proton.me', 'user.name', 'protonmail.com'], + ['user.name', 'pm.me', 'user.name', 'protonmail.com'], + ]; + + foreach ($testCases as [$inputLocal, $inputDomain, $expectedLocal, $expectedDomain]) { + $result = $this->provider->getCanonical($inputLocal, $inputDomain); + $this->assertEquals($expectedLocal, $result['local'], "Failed for local: {$inputLocal}@{$inputDomain}"); + $this->assertEquals($expectedDomain, $result['domain'], "Failed for domain: {$inputLocal}@{$inputDomain}"); + } + } + + public function test_get_canonical_domain(): void + { + $this->assertEquals('protonmail.com', $this->provider->getCanonicalDomain()); + } + + public function test_get_supported_domains(): void + { + $domains = $this->provider->getSupportedDomains(); + $expected = ['protonmail.com', 'proton.me', 'pm.me']; + $this->assertEquals($expected, $domains); + } +} diff --git a/tests/Canonicals/Providers/YahooTest.php b/tests/Canonicals/Providers/YahooTest.php new file mode 100644 index 0000000..ee1ac5b --- /dev/null +++ b/tests/Canonicals/Providers/YahooTest.php @@ -0,0 +1,102 @@ +provider = new Yahoo; + } + + public function test_supports(): void + { + $this->assertTrue($this->provider->supports('yahoo.com')); + $this->assertTrue($this->provider->supports('yahoo.co.uk')); + $this->assertTrue($this->provider->supports('yahoo.ca')); + $this->assertTrue($this->provider->supports('ymail.com')); + $this->assertTrue($this->provider->supports('rocketmail.com')); + $this->assertFalse($this->provider->supports('gmail.com')); + $this->assertFalse($this->provider->supports('outlook.com')); + $this->assertFalse($this->provider->supports('example.com')); + } + + public function test_get_canonical(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of Yahoo's plus addressing, dots, and hyphens support + // ['user.name+tag', 'yahoo.com', 'username', 'yahoo.com'], + // ['user.name+spam', 'yahoo.com', 'username', 'yahoo.com'], + // ['user.name+newsletter', 'yahoo.com', 'username', 'yahoo.com'], + // ['user.name+work', 'yahoo.com', 'username', 'yahoo.com'], + // ['user.name+personal', 'yahoo.com', 'username', 'yahoo.com'], + // ['user.name+test123', 'yahoo.com', 'username', 'yahoo.com'], + // ['user.name+anything', 'yahoo.com', 'username', 'yahoo.com'], + // ['user.name+verylongtag', 'yahoo.com', 'username', 'yahoo.com'], + // ['user.name+tag.with.dots', 'yahoo.com', 'username', 'yahoo.com'], + // ['user.name+tag-with-hyphens', 'yahoo.com', 'username', 'yahoo.com'], + // ['user.name+tag_with_underscores', 'yahoo.com', 'username', 'yahoo.com'], + // ['user.name+tag123', 'yahoo.com', 'username', 'yahoo.com'], + // // Hyphen removal + // ['user-name', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+tag', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+spam', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+newsletter', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+work', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+personal', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+test123', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+anything', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+verylongtag', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+tag.with.dots', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+tag-with-hyphens', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+tag_with_underscores', 'yahoo.com', 'username', 'yahoo.com'], + // ['user-name+tag123', 'yahoo.com', 'username', 'yahoo.com'], + // // Multiple hyphens + // ['u-s-e-r-n-a-m-e', 'yahoo.com', 'username', 'yahoo.com'], + // ['u-s-e-r-n-a-m-e+tag', 'yahoo.com', 'username', 'yahoo.com'], + // // Other Yahoo domains + // ['user.name+tag', 'yahoo.co.uk', 'username', 'yahoo.com'], + // ['user.name+tag', 'yahoo.ca', 'username', 'yahoo.com'], + // ['user.name+tag', 'ymail.com', 'username', 'yahoo.com'], + // ['user.name+tag', 'rocketmail.com', 'username', 'yahoo.com'], + // // Edge cases + // ['user+', 'yahoo.com', 'user', 'yahoo.com'], + // ['user-', 'yahoo.com', 'user', 'yahoo.com'], + // Dots and hyphens are preserved for Yahoo + ['user.name', 'yahoo.com', 'user.name', 'yahoo.com'], + ['user-name', 'yahoo.com', 'user-name', 'yahoo.com'], + ['u.s.e.r.n.a.m.e', 'yahoo.com', 'u.s.e.r.n.a.m.e', 'yahoo.com'], + ['u-s-e-r-n-a-m-e', 'yahoo.com', 'u-s-e-r-n-a-m-e', 'yahoo.com'], + ['user.', 'yahoo.com', 'user.', 'yahoo.com'], + ['.user', 'yahoo.com', '.user', 'yahoo.com'], + // Other Yahoo domains + ['user.name', 'yahoo.co.uk', 'user.name', 'yahoo.com'], + ['user.name', 'yahoo.ca', 'user.name', 'yahoo.com'], + ['user.name', 'ymail.com', 'user.name', 'yahoo.com'], + ['user.name', 'rocketmail.com', 'user.name', 'yahoo.com'], + ]; + + foreach ($testCases as [$inputLocal, $inputDomain, $expectedLocal, $expectedDomain]) { + $result = $this->provider->getCanonical($inputLocal, $inputDomain); + $this->assertEquals($expectedLocal, $result['local'], "Failed for local: {$inputLocal}@{$inputDomain}"); + $this->assertEquals($expectedDomain, $result['domain'], "Failed for domain: {$inputLocal}@{$inputDomain}"); + } + } + + public function test_get_canonical_domain(): void + { + $this->assertEquals('yahoo.com', $this->provider->getCanonicalDomain()); + } + + public function test_get_supported_domains(): void + { + $domains = $this->provider->getSupportedDomains(); + $expected = ['yahoo.com', 'yahoo.co.uk', 'yahoo.ca', 'ymail.com', 'rocketmail.com']; + $this->assertEquals($expected, $domains); + } +} diff --git a/tests/EmailTest.php b/tests/EmailTest.php new file mode 100644 index 0000000..c58e8bb --- /dev/null +++ b/tests/EmailTest.php @@ -0,0 +1,900 @@ +assertEquals('test@company.org', $email->get()); + $this->assertEquals('test', $email->getLocal()); + $this->assertEquals('company.org', $email->getDomain()); + $this->assertEquals('company.org', $email->getDomain()); + $this->assertEquals('test', $email->getLocal()); + $this->assertEquals(true, $email->isValid()); + $this->assertEquals(true, $email->hasValidLocal()); + $this->assertEquals(true, $email->hasValidDomain()); + $this->assertEquals(false, $email->isDisposable()); + $this->assertEquals(false, $email->isFree()); + $this->assertEquals(true, $email->isCorporate()); + $this->assertEquals('company.org', $email->getProvider()); + $this->assertEquals('', $email->getSubdomain()); + $this->assertEquals(false, $email->hasSubdomain()); + $this->assertEquals('test@company.org', $email->getAddress()); + } + + public function test_email_with_subdomain(): void + { + $email = new Email('user@mail.company.org'); + + $this->assertEquals('user@mail.company.org', $email->get()); + $this->assertEquals('user', $email->getLocal()); + $this->assertEquals('mail.company.org', $email->getDomain()); + $this->assertEquals('company.org', $email->getProvider()); + $this->assertEquals('mail', $email->getSubdomain()); + $this->assertEquals(true, $email->hasSubdomain()); + } + + public function test_gmail_email(): void + { + $email = new Email('user@gmail.com'); + + $this->assertEquals('user@gmail.com', $email->get()); + $this->assertEquals('user', $email->getLocal()); + $this->assertEquals('gmail.com', $email->getDomain()); + $this->assertEquals(false, $email->isDisposable()); + $this->assertEquals(true, $email->isFree()); + $this->assertEquals(false, $email->isCorporate()); + $this->assertEquals('gmail.com', $email->getProvider()); + } + + public function test_disposable_email(): void + { + $email = new Email('user@10minutemail.com'); + + $this->assertEquals('user@10minutemail.com', $email->get()); + $this->assertEquals('user', $email->getLocal()); + $this->assertEquals('10minutemail.com', $email->getDomain()); + $this->assertEquals(true, $email->isDisposable()); + $this->assertEquals(false, $email->isFree()); + $this->assertEquals(false, $email->isCorporate()); + } + + public function test_email_with_special_characters(): void + { + $email = new Email('user.name+tag@company.org'); + + $this->assertEquals('user.name+tag@company.org', $email->get()); + $this->assertEquals('user.name+tag', $email->getLocal()); + $this->assertEquals('company.org', $email->getDomain()); + $this->assertEquals(true, $email->isValid()); + $this->assertEquals(true, $email->hasValidLocal()); + $this->assertEquals(true, $email->hasValidDomain()); + } + + public function test_email_with_hyphens(): void + { + $email = new Email('user-name@example-domain.com'); + + $this->assertEquals('user-name@example-domain.com', $email->get()); + $this->assertEquals('user-name', $email->getLocal()); + $this->assertEquals('example-domain.com', $email->getDomain()); + $this->assertEquals(true, $email->isValid()); + $this->assertEquals(true, $email->hasValidLocal()); + $this->assertEquals(true, $email->hasValidDomain()); + } + + public function test_email_with_underscores(): void + { + $email = new Email('user_name@company.org'); + + $this->assertEquals('user_name@company.org', $email->get()); + $this->assertEquals('user_name', $email->getLocal()); + $this->assertEquals('company.org', $email->getDomain()); + $this->assertEquals(true, $email->isValid()); + $this->assertEquals(true, $email->hasValidLocal()); + $this->assertEquals(true, $email->hasValidDomain()); + } + + public function test_email_with_numbers(): void + { + $email = new Email('user123@example123.com'); + + $this->assertEquals('user123@example123.com', $email->get()); + $this->assertEquals('user123', $email->getLocal()); + $this->assertEquals('example123.com', $email->getDomain()); + $this->assertEquals(true, $email->isValid()); + $this->assertEquals(true, $email->hasValidLocal()); + $this->assertEquals(true, $email->hasValidDomain()); + } + + public function test_email_with_multiple_dots(): void + { + $email = new Email('user.name.last@company.org'); + + $this->assertEquals('user.name.last@company.org', $email->get()); + $this->assertEquals('user.name.last', $email->getLocal()); + $this->assertEquals('company.org', $email->getDomain()); + $this->assertEquals(true, $email->isValid()); + $this->assertEquals(true, $email->hasValidLocal()); + $this->assertEquals(true, $email->hasValidDomain()); + } + + public function test_email_with_multiple_subdomains(): void + { + $email = new Email('user@mail.sub.company.org'); + + $this->assertEquals('user@mail.sub.company.org', $email->get()); + $this->assertEquals('user', $email->getLocal()); + $this->assertEquals('mail.sub.company.org', $email->getDomain()); + $this->assertEquals('company.org', $email->getProvider()); + $this->assertEquals('mail.sub', $email->getSubdomain()); + $this->assertEquals(true, $email->hasSubdomain()); + } + + public function test_email_formatted(): void + { + $email = new Email('user@mail.company.org'); + + $this->assertEquals('user@mail.company.org', $email->getFormatted('full')); + $this->assertEquals('user', $email->getFormatted('local')); + $this->assertEquals('mail.company.org', $email->getFormatted('domain')); + $this->assertEquals('company.org', $email->getFormatted('provider')); + $this->assertEquals('mail', $email->getFormatted('subdomain')); + } + + public function test_email_normalization(): void + { + $email = new Email(' USER@COMPANY.ORG '); + + $this->assertEquals('user@company.org', $email->get()); + $this->assertEquals('user@company.org', $email->getAddress()); + } + + public function test_invalid_email_empty(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Email address cannot be empty'); + + new Email(''); + } + + public function test_invalid_email_no_at(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'invalid-email' must be a valid email address"); + + new Email('invalid-email'); + } + + public function test_invalid_email_multiple_at(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'user@example@com' must be a valid email address"); + + new Email('user@example@com'); + } + + public function test_invalid_email_no_local(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'@example.com' must be a valid email address"); + + new Email('@example.com'); + } + + public function test_invalid_email_no_domain(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage("'user@' must be a valid email address"); + + new Email('user@'); + } + + public function test_invalid_email_consecutive_dots(): void + { + $email = new Email('user..name@example.com'); + + $this->assertEquals(false, $email->hasValidLocal()); + } + + public function test_invalid_email_starts_with_dot(): void + { + $email = new Email('.user@example.com'); + + $this->assertEquals(false, $email->hasValidLocal()); + } + + public function test_invalid_email_ends_with_dot(): void + { + $email = new Email('user.@example.com'); + + $this->assertEquals(false, $email->hasValidLocal()); + } + + public function test_invalid_email_local_too_long(): void + { + $longLocal = str_repeat('a', 65); // 65 characters + $email = new Email($longLocal.'@example.com'); + + $this->assertEquals(false, $email->hasValidLocal()); + } + + public function test_invalid_email_domain_too_long(): void + { + $longDomain = str_repeat('a', 250).'.com'; // 254 characters + $email = new Email('user@'.$longDomain); + + $this->assertEquals(false, $email->hasValidDomain()); + } + + public function test_invalid_email_domain_consecutive_dots(): void + { + $email = new Email('user@example..com'); + + $this->assertEquals(false, $email->hasValidDomain()); + } + + public function test_invalid_email_domain_consecutive_hyphens(): void + { + $email = new Email('user@example--com.com'); + + // filter_var allows consecutive hyphens, so this will be valid + $this->assertEquals(true, $email->hasValidDomain()); + } + + public function test_invalid_email_domain_starts_with_dot(): void + { + $email = new Email('user@.example.com'); + + $this->assertEquals(false, $email->hasValidDomain()); + } + + public function test_invalid_email_domain_ends_with_dot(): void + { + $email = new Email('user@example.com.'); + + $this->assertEquals(false, $email->hasValidDomain()); + } + + public function test_invalid_email_domain_starts_with_hyphen(): void + { + $email = new Email('user@-example.com'); + + $this->assertEquals(false, $email->hasValidDomain()); + } + + public function test_invalid_email_domain_ends_with_hyphen(): void + { + $email = new Email('user@example-.com'); + + $this->assertEquals(false, $email->hasValidDomain()); + } + + public function test_invalid_email_domain_no_tld(): void + { + $email = new Email('user@example'); + + $this->assertEquals(false, $email->hasValidDomain()); + } + + public function test_invalid_email_domain_invalid_characters(): void + { + $email = new Email('user@example!.com'); + + $this->assertEquals(false, $email->hasValidDomain()); + } + + public function test_invalid_email_local_invalid_characters(): void + { + $email = new Email('user!@example.com'); + + $this->assertEquals(false, $email->hasValidLocal()); + } + + public function test_free_email_providers(): void + { + $freeProviders = [ + 'gmail.com', + 'yahoo.com', + 'hotmail.com', + 'outlook.com', + 'live.com', + 'aol.com', + 'icloud.com', + 'protonmail.com', + 'zoho.com', + 'yandex.com', + 'mail.com', + 'gmx.com', + 'web.de', + 'tutanota.com', + 'fastmail.com', + 'hey.com', + ]; + + foreach ($freeProviders as $provider) { + $email = new Email('user@'.$provider); + $this->assertEquals(true, $email->isFree(), "Failed for provider: {$provider}"); + $this->assertEquals(false, $email->isCorporate(), "Failed for provider: {$provider}"); + } + } + + public function test_disposable_email_providers(): void + { + $disposableProviders = [ + '10minutemail.com', + 'tempmail.org', + 'guerrillamail.com', + 'mailinator.com', + 'yopmail.com', + 'temp-mail.org', + 'throwaway.email', + 'getnada.com', + 'maildrop.cc', + 'sharklasers.com', + 'test.com', + ]; + + foreach ($disposableProviders as $provider) { + $email = new Email('user@'.$provider); + $this->assertEquals(true, $email->isDisposable(), "Failed for provider: {$provider}"); + $this->assertEquals(false, $email->isCorporate(), "Failed for provider: {$provider}"); + } + } + + public function test_corporate_email_providers(): void + { + $corporateProviders = [ + 'company.com', + 'business.org', + 'enterprise.net', + 'corporation.co.uk', + 'organization.org', + 'firm.com', + 'office.net', + 'work.org', + ]; + + foreach ($corporateProviders as $provider) { + $email = new Email('user@'.$provider); + $this->assertEquals(false, $email->isFree(), "Failed for provider: {$provider}"); + $this->assertEquals(false, $email->isDisposable(), "Failed for provider: {$provider}"); + $this->assertEquals(true, $email->isCorporate(), "Failed for provider: {$provider}"); + } + } + + public function test_get_unique_gmail_aliases(): void + { + $testCases = [ + // Gmail dot notation and plus addressing + ['user.name@gmail.com', 'username@gmail.com'], + ['user.name+tag@gmail.com', 'username@gmail.com'], + ['user.name+spam@gmail.com', 'username@gmail.com'], + ['user.name+newsletter@gmail.com', 'username@gmail.com'], + ['user.name+work@gmail.com', 'username@gmail.com'], + ['user.name+personal@gmail.com', 'username@gmail.com'], + ['user.name+test123@gmail.com', 'username@gmail.com'], + ['user.name+anything@gmail.com', 'username@gmail.com'], + ['user.name+verylongtag@gmail.com', 'username@gmail.com'], + ['user.name+tag.with.dots@gmail.com', 'username@gmail.com'], + ['user.name+tag-with-hyphens@gmail.com', 'username@gmail.com'], + ['user.name+tag_with_underscores@gmail.com', 'username@gmail.com'], + ['user.name+tag123@gmail.com', 'username@gmail.com'], + ['user.name+tag@googlemail.com', 'username@gmail.com'], + ['user.name+tag@googlemail.com', 'username@gmail.com'], + ['user.name+spam@googlemail.com', 'username@gmail.com'], + ['user.name@googlemail.com', 'username@gmail.com'], + // Multiple dots + ['u.s.e.r.n.a.m.e@gmail.com', 'username@gmail.com'], + ['u.s.e.r.n.a.m.e+tag@gmail.com', 'username@gmail.com'], + // Edge cases + ['user+@gmail.com', 'user@gmail.com'], + ['user.@gmail.com', 'user@gmail.com'], + ['.user@gmail.com', 'user@gmail.com'], + ['user..name@gmail.com', 'username@gmail.com'], + ]; + + foreach ($testCases as [$input, $expected]) { + $email = new Email($input); + $this->assertEquals($expected, $email->getCanonical(), "Failed for input: {$input}"); + } + } + + public function test_get_unique_outlook_aliases(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of Outlook's plus addressing support + // // Outlook/Hotmail/Live plus addressing + // ['user.name+tag@outlook.com', 'user.name@outlook.com'], + // ['user.name+spam@outlook.com', 'user.name@outlook.com'], + // ['user.name+newsletter@outlook.com', 'user.name@outlook.com'], + // ['user.name+work@outlook.com', 'user.name@outlook.com'], + // ['user.name+personal@outlook.com', 'user.name@outlook.com'], + // ['user.name+test123@outlook.com', 'user.name@outlook.com'], + // ['user.name+anything@outlook.com', 'user.name@outlook.com'], + // ['user.name+verylongtag@outlook.com', 'user.name@outlook.com'], + // ['user.name+tag.with.dots@outlook.com', 'user.name@outlook.com'], + // ['user.name+tag-with-hyphens@outlook.com', 'user.name@outlook.com'], + // ['user.name+tag_with_underscores@outlook.com', 'user.name@outlook.com'], + // ['user.name+tag123@outlook.com', 'user.name@outlook.com'], + // // Hotmail + // ['user.name+tag@hotmail.com', 'user.name@outlook.com'], + // ['user.name+spam@hotmail.com', 'user.name@outlook.com'], + // ['user.name@hotmail.com', 'user.name@outlook.com'], + // // Live + // ['user.name+tag@live.com', 'user.name@outlook.com'], + // ['user.name+spam@live.com', 'user.name@outlook.com'], + // ['user.name@live.com', 'user.name@outlook.com'], + // // UK variants + // ['user.name+tag@outlook.co.uk', 'user.name@outlook.com'], + // ['user.name+tag@hotmail.co.uk', 'user.name@outlook.com'], + // ['user.name+tag@live.co.uk', 'user.name@outlook.com'], + // Dots are preserved for Outlook + ['user.name@outlook.com', 'user.name@outlook.com'], + ['u.s.e.r.n.a.m.e@outlook.com', 'u.s.e.r.n.a.m.e@outlook.com'], + // Edge cases + // ['user+@outlook.com', 'user@outlook.com'], + ['user.@outlook.com', 'user.@outlook.com'], + ['.user@outlook.com', '.user@outlook.com'], + // Hotmail + ['user.name@hotmail.com', 'user.name@outlook.com'], + // Live + ['user.name@live.com', 'user.name@outlook.com'], + // UK variants + ['user.name@outlook.co.uk', 'user.name@outlook.com'], + ['user.name@hotmail.co.uk', 'user.name@outlook.com'], + ['user.name@live.co.uk', 'user.name@outlook.com'], + ]; + + foreach ($testCases as [$input, $expected]) { + $email = new Email($input); + $this->assertEquals($expected, $email->getCanonical(), "Failed for input: {$input}"); + } + } + + public function test_get_unique_yahoo_aliases(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of Yahoo's plus addressing, dots, and hyphens support + // // Yahoo plus addressing and hyphen removal + // ['user.name+tag@yahoo.com', 'username@yahoo.com'], + // ['user.name+spam@yahoo.com', 'username@yahoo.com'], + // ['user.name+newsletter@yahoo.com', 'username@yahoo.com'], + // ['user.name+work@yahoo.com', 'username@yahoo.com'], + // ['user.name+personal@yahoo.com', 'username@yahoo.com'], + // ['user.name+test123@yahoo.com', 'username@yahoo.com'], + // ['user.name+anything@yahoo.com', 'username@yahoo.com'], + // ['user.name+verylongtag@yahoo.com', 'username@yahoo.com'], + // ['user.name+tag.with.dots@yahoo.com', 'username@yahoo.com'], + // ['user.name+tag-with-hyphens@yahoo.com', 'username@yahoo.com'], + // ['user.name+tag_with_underscores@yahoo.com', 'username@yahoo.com'], + // ['user.name+tag123@yahoo.com', 'username@yahoo.com'], + // // Hyphen removal + // ['user-name@yahoo.com', 'username@yahoo.com'], + // ['user-name+tag@yahoo.com', 'username@yahoo.com'], + // ['user-name+spam@yahoo.com', 'username@yahoo.com'], + // ['user-name+newsletter@yahoo.com', 'username@yahoo.com'], + // ['user-name+work@yahoo.com', 'username@yahoo.com'], + // ['user-name+personal@yahoo.com', 'username@yahoo.com'], + // ['user-name+test123@yahoo.com', 'username@yahoo.com'], + // ['user-name+anything@yahoo.com', 'username@yahoo.com'], + // ['user-name+verylongtag@yahoo.com', 'username@yahoo.com'], + // ['user-name+tag.with.dots@yahoo.com', 'username@yahoo.com'], + // ['user-name+tag-with-hyphens@yahoo.com', 'username@yahoo.com'], + // ['user-name+tag_with_underscores@yahoo.com', 'username@yahoo.com'], + // ['user-name+tag123@yahoo.com', 'username@yahoo.com'], + // // Multiple hyphens + // ['u-s-e-r-n-a-m-e@yahoo.com', 'username@yahoo.com'], + // ['u-s-e-r-n-a-m-e+tag@yahoo.com', 'username@yahoo.com'], + // // Other Yahoo domains + // ['user.name+tag@yahoo.co.uk', 'username@yahoo.com'], + // ['user.name+tag@yahoo.ca', 'username@yahoo.com'], + // ['user.name+tag@ymail.com', 'username@yahoo.com'], + // ['user.name+tag@rocketmail.com', 'username@yahoo.com'], + // // Edge cases + // ['user+@yahoo.com', 'user@yahoo.com'], + // ['user-@yahoo.com', 'user@yahoo.com'], + // Dots and hyphens are preserved for Yahoo + ['user.name@yahoo.com', 'user.name@yahoo.com'], + ['user-name@yahoo.com', 'user-name@yahoo.com'], + ['u.s.e.r.n.a.m.e@yahoo.com', 'u.s.e.r.n.a.m.e@yahoo.com'], + ['u-s-e-r-n-a-m-e@yahoo.com', 'u-s-e-r-n-a-m-e@yahoo.com'], + ['user.@yahoo.com', 'user.@yahoo.com'], + ['.user@yahoo.com', '.user@yahoo.com'], + // Other Yahoo domains + ['user.name@yahoo.co.uk', 'user.name@yahoo.com'], + ['user.name@yahoo.ca', 'user.name@yahoo.com'], + ['user.name@ymail.com', 'user.name@yahoo.com'], + ['user.name@rocketmail.com', 'user.name@yahoo.com'], + ]; + + foreach ($testCases as [$input, $expected]) { + $email = new Email($input); + $this->assertEquals($expected, $email->getCanonical(), "Failed for input: {$input}"); + } + } + + public function test_get_unique_icloud_aliases(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of iCloud's plus addressing and dots support + // // iCloud plus addressing + // ['user.name+tag@icloud.com', 'username@icloud.com'], + // ['user.name+spam@icloud.com', 'username@icloud.com'], + // ['user.name+newsletter@icloud.com', 'username@icloud.com'], + // ['user.name+work@icloud.com', 'username@icloud.com'], + // ['user.name+personal@icloud.com', 'username@icloud.com'], + // ['user.name+test123@icloud.com', 'username@icloud.com'], + // ['user.name+anything@icloud.com', 'username@icloud.com'], + // ['user.name+verylongtag@icloud.com', 'username@icloud.com'], + // ['user.name+tag.with.dots@icloud.com', 'username@icloud.com'], + // ['user.name+tag-with-hyphens@icloud.com', 'username@icloud.com'], + // ['user.name+tag_with_underscores@icloud.com', 'username@icloud.com'], + // ['user.name+tag123@icloud.com', 'username@icloud.com'], + // // Other Apple domains + // ['user.name+tag@me.com', 'username@icloud.com'], + // ['user.name+tag@mac.com', 'username@icloud.com'], + // Dots are preserved for iCloud + ['user.name@icloud.com', 'user.name@icloud.com'], + ['u.s.e.r.n.a.m.e@icloud.com', 'u.s.e.r.n.a.m.e@icloud.com'], + // Edge cases + // ['user+@icloud.com', 'user@icloud.com'], + ['user.@icloud.com', 'user.@icloud.com'], + ['.user@icloud.com', '.user@icloud.com'], + // Other Apple domains + ['user.name@me.com', 'user.name@icloud.com'], + ['user.name@mac.com', 'user.name@icloud.com'], + ]; + + foreach ($testCases as [$input, $expected]) { + $email = new Email($input); + $this->assertEquals($expected, $email->getCanonical(), "Failed for input: {$input}"); + } + } + + public function test_get_unique_protonmail_aliases(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of ProtonMail's plus addressing and dots support + // // ProtonMail plus addressing + // ['user.name+tag@protonmail.com', 'username@protonmail.com'], + // ['user.name+spam@protonmail.com', 'username@protonmail.com'], + // ['user.name+newsletter@protonmail.com', 'username@protonmail.com'], + // ['user.name+work@protonmail.com', 'username@protonmail.com'], + // ['user.name+personal@protonmail.com', 'username@protonmail.com'], + // ['user.name+test123@protonmail.com', 'username@protonmail.com'], + // ['user.name+anything@protonmail.com', 'username@protonmail.com'], + // ['user.name+verylongtag@protonmail.com', 'username@protonmail.com'], + // ['user.name+tag.with.dots@protonmail.com', 'username@protonmail.com'], + // ['user.name+tag-with-hyphens@protonmail.com', 'username@protonmail.com'], + // ['user.name+tag_with_underscores@protonmail.com', 'username@protonmail.com'], + // ['user.name+tag123@protonmail.com', 'username@protonmail.com'], + // // Other ProtonMail domains + // ['user.name+tag@proton.me', 'username@protonmail.com'], + // ['user.name+tag@pm.me', 'username@protonmail.com'], + // Dots are preserved for ProtonMail + ['user.name@protonmail.com', 'user.name@protonmail.com'], + ['u.s.e.r.n.a.m.e@protonmail.com', 'u.s.e.r.n.a.m.e@protonmail.com'], + // Edge cases + // ['user+@protonmail.com', 'user@protonmail.com'], + ['user.@protonmail.com', 'user.@protonmail.com'], + ['.user@protonmail.com', '.user@protonmail.com'], + // Other ProtonMail domains + ['user.name@proton.me', 'user.name@protonmail.com'], + ['user.name@pm.me', 'user.name@protonmail.com'], + ]; + + foreach ($testCases as [$input, $expected]) { + $email = new Email($input); + $this->assertEquals($expected, $email->getCanonical(), "Failed for input: {$input}"); + } + } + + public function test_get_unique_fastmail_aliases(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of Fastmail's plus addressing and dots support + // // Fastmail plus addressing + // ['user.name+tag@fastmail.com', 'username@fastmail.com'], + // ['user.name+spam@fastmail.com', 'username@fastmail.com'], + // ['user.name+newsletter@fastmail.com', 'username@fastmail.com'], + // ['user.name+work@fastmail.com', 'username@fastmail.com'], + // ['user.name+personal@fastmail.com', 'username@fastmail.com'], + // ['user.name+test123@fastmail.com', 'username@fastmail.com'], + // ['user.name+anything@fastmail.com', 'username@fastmail.com'], + // ['user.name+verylongtag@fastmail.com', 'username@fastmail.com'], + // ['user.name+tag.with.dots@fastmail.com', 'username@fastmail.com'], + // ['user.name+tag-with-hyphens@fastmail.com', 'username@fastmail.com'], + // ['user.name+tag_with_underscores@fastmail.com', 'username@fastmail.com'], + // ['user.name+tag123@fastmail.com', 'username@fastmail.com'], + // // Other Fastmail domain + // ['user.name+tag@fastmail.fm', 'username@fastmail.com'], + // Dots are preserved for Fastmail + ['user.name@fastmail.com', 'user.name@fastmail.com'], + ['u.s.e.r.n.a.m.e@fastmail.com', 'u.s.e.r.n.a.m.e@fastmail.com'], + // Edge cases + // ['user+@fastmail.com', 'user@fastmail.com'], + ['user.@fastmail.com', 'user.@fastmail.com'], + ['.user@fastmail.com', '.user@fastmail.com'], + // Other Fastmail domain + ['user.name@fastmail.fm', 'user.name@fastmail.com'], + ]; + + foreach ($testCases as [$input, $expected]) { + $email = new Email($input); + $this->assertEquals($expected, $email->getCanonical(), "Failed for input: {$input}"); + } + } + + public function test_get_unique_other_domains(): void + { + $testCases = [ + // TODO: Commented out until manual confirmation of generic providers' plus addressing, dots, and hyphens support + // // Other domains with plus addressing + // ['user.name+tag@example.com', 'username@example.com'], + // ['user.name+spam@example.com', 'username@example.com'], + // ['user.name+newsletter@example.com', 'username@example.com'], + // ['user.name+work@example.com', 'username@example.com'], + // ['user.name+personal@example.com', 'username@example.com'], + // ['user.name+test123@example.com', 'username@example.com'], + // ['user.name+anything@example.com', 'username@example.com'], + // ['user.name+verylongtag@example.com', 'username@example.com'], + // ['user.name+tag.with.dots@example.com', 'username@example.com'], + // ['user.name+tag-with-hyphens@example.com', 'username@example.com'], + // ['user.name+tag_with_underscores@example.com', 'username@example.com'], + // ['user.name+tag123@example.com', 'username@example.com'], + // Dots are preserved for other domains + ['user.name@example.com', 'user.name@example.com'], + ['u.s.e.r.n.a.m.e@example.com', 'u.s.e.r.n.a.m.e@example.com'], + // Hyphens are preserved for other domains + ['user-name@example.com', 'user-name@example.com'], + // ['user-name+tag@example.com', 'username@example.com'], + // Edge cases + // ['user+@example.com', 'user@example.com'], + ['user.@example.com', 'user.@example.com'], + ['.user@example.com', '.user@example.com'], + ]; + + foreach ($testCases as [$input, $expected]) { + $email = new Email($input); + $this->assertEquals($expected, $email->getCanonical(), "Failed for input: {$input}"); + } + } + + public function test_get_unique_edge_cases(): void + { + $testCases = [ + // Empty plus addressing + ['user+@gmail.com', 'user@gmail.com'], + // TODO: Commented out until manual confirmation of Outlook's plus addressing support + // ['user+@outlook.com', 'user@outlook.com'], + // TODO: Commented out until manual confirmation of non-Gmail providers' plus addressing support + // ['user+@yahoo.com', 'user@yahoo.com'], + // ['user+@icloud.com', 'user@icloud.com'], + // ['user+@protonmail.com', 'user@protonmail.com'], + // ['user+@fastmail.com', 'user@fastmail.com'], + // ['user+@example.com', 'user@example.com'], + // Plus at the beginning + ['+user@gmail.com', '+user@gmail.com'], + ['+user@outlook.com', '+user@outlook.com'], + ['+user@yahoo.com', '+user@yahoo.com'], + ['+user@icloud.com', '+user@icloud.com'], + ['+user@protonmail.com', '+user@protonmail.com'], + ['+user@fastmail.com', '+user@fastmail.com'], + ['+user@example.com', '+user@example.com'], + // Multiple plus signs (only first one is considered) + ['user+tag+more@gmail.com', 'user@gmail.com'], + // TODO: Commented out until manual confirmation of Outlook's plus addressing support + // ['user+tag+more@outlook.com', 'user@outlook.com'], + // TODO: Commented out until manual confirmation of non-Gmail providers' plus addressing support + // ['user+tag+more@yahoo.com', 'user@yahoo.com'], + // ['user+tag+more@icloud.com', 'user@icloud.com'], + // ['user+tag+more@protonmail.com', 'user@protonmail.com'], + // ['user+tag+more@fastmail.com', 'user@fastmail.com'], + // ['user+tag+more@example.com', 'user@example.com'], + // Special characters in plus addressing + ['user+tag!@gmail.com', 'user@gmail.com'], + ['user+tag#@gmail.com', 'user@gmail.com'], + ['user+tag$@gmail.com', 'user@gmail.com'], + ['user+tag%@gmail.com', 'user@gmail.com'], + ['user+tag&@gmail.com', 'user@gmail.com'], + ['user+tag*@gmail.com', 'user@gmail.com'], + ['user+tag(@gmail.com', 'user@gmail.com'], + ['user+tag)@gmail.com', 'user@gmail.com'], + ['user+tag=@gmail.com', 'user@gmail.com'], + ['user+tag[@gmail.com', 'user@gmail.com'], + ['user+tag]@gmail.com', 'user@gmail.com'], + ['user+tag{@gmail.com', 'user@gmail.com'], + ['user+tag}@gmail.com', 'user@gmail.com'], + ['user+tag|@gmail.com', 'user@gmail.com'], + ['user+tag\@gmail.com', 'user@gmail.com'], + ['user+tag/@gmail.com', 'user@gmail.com'], + ['user+tag?@gmail.com', 'user@gmail.com'], + ['user+tag<@gmail.com', 'user@gmail.com'], + ['user+tag>@gmail.com', 'user@gmail.com'], + ['user+tag,@gmail.com', 'user@gmail.com'], + ['user+tag;@gmail.com', 'user@gmail.com'], + ['user+tag:@gmail.com', 'user@gmail.com'], + ['user+tag"@gmail.com', 'user@gmail.com'], + ['user+tag\'@gmail.com', 'user@gmail.com'], + ['user+tag~@gmail.com', 'user@gmail.com'], + ['user+tag`@gmail.com', 'user@gmail.com'], + ]; + + foreach ($testCases as [$input, $expected]) { + $email = new Email($input); + $this->assertEquals($expected, $email->getCanonical(), "Failed for input: {$input}"); + } + } + + public function test_get_unique_case_sensitivity(): void + { + $testCases = [ + // Case sensitivity should not matter + ['USER.NAME+TAG@GMAIL.COM', 'username@gmail.com'], + ['User.Name+Tag@Gmail.Com', 'username@gmail.com'], + ['user.name+tag@Gmail.com', 'username@gmail.com'], + // TODO: Commented out until manual confirmation of Outlook's plus addressing support + // ['USER.NAME+TAG@OUTLOOK.COM', 'user.name@outlook.com'], + // ['User.Name+Tag@Outlook.Com', 'user.name@outlook.com'], + // ['user.name+tag@Outlook.com', 'user.name@outlook.com'], + // Dots are preserved for Outlook + ['USER.NAME@OUTLOOK.COM', 'user.name@outlook.com'], + ['User.Name@Outlook.Com', 'user.name@outlook.com'], + ['user.name@Outlook.com', 'user.name@outlook.com'], + // TODO: Commented out until manual confirmation of non-Gmail providers' plus addressing and dots support + // ['USER.NAME+TAG@YAHOO.COM', 'username@yahoo.com'], + // ['User.Name+Tag@Yahoo.Com', 'username@yahoo.com'], + // ['user.name+tag@Yahoo.com', 'username@yahoo.com'], + // ['USER.NAME+TAG@ICLOUD.COM', 'username@icloud.com'], + // ['User.Name+Tag@Icloud.Com', 'username@icloud.com'], + // ['user.name+tag@Icloud.com', 'username@icloud.com'], + // ['USER.NAME+TAG@PROTONMAIL.COM', 'username@protonmail.com'], + // ['User.Name+Tag@Protonmail.Com', 'username@protonmail.com'], + // ['user.name+tag@Protonmail.com', 'username@protonmail.com'], + // ['USER.NAME+TAG@FASTMAIL.COM', 'username@fastmail.com'], + // ['User.Name+Tag@Fastmail.Com', 'username@fastmail.com'], + // ['user.name+tag@Fastmail.com', 'username@fastmail.com'], + // ['USER.NAME+TAG@EXAMPLE.COM', 'username@example.com'], + // ['User.Name+Tag@Example.Com', 'username@example.com'], + // ['user.name+tag@Example.com', 'username@example.com'], + // Dots and pluses are preserved for non-Gmail providers + ['USER.NAME@YAHOO.COM', 'user.name@yahoo.com'], + ['User.Name@Yahoo.Com', 'user.name@yahoo.com'], + ['user.name@Yahoo.com', 'user.name@yahoo.com'], + ['USER.NAME@ICLOUD.COM', 'user.name@icloud.com'], + ['User.Name@Icloud.Com', 'user.name@icloud.com'], + ['user.name@Icloud.com', 'user.name@icloud.com'], + ['USER.NAME@PROTONMAIL.COM', 'user.name@protonmail.com'], + ['User.Name@Protonmail.Com', 'user.name@protonmail.com'], + ['user.name@Protonmail.com', 'user.name@protonmail.com'], + ['USER.NAME@FASTMAIL.COM', 'user.name@fastmail.com'], + ['User.Name@Fastmail.Com', 'user.name@fastmail.com'], + ['user.name@Fastmail.com', 'user.name@fastmail.com'], + ['USER.NAME@EXAMPLE.COM', 'user.name@example.com'], + ['User.Name@Example.Com', 'user.name@example.com'], + ['user.name@Example.com', 'user.name@example.com'], + ]; + + foreach ($testCases as [$input, $expected]) { + $email = new Email($input); + $this->assertEquals($expected, $email->getCanonical(), "Failed for input: {$input}"); + } + } + + public function test_is_normalization_supported(): void + { + $supportedEmails = [ + 'user@gmail.com', + 'user@googlemail.com', + 'user@outlook.com', + 'user@hotmail.com', + 'user@live.com', + 'user@outlook.co.uk', + 'user@hotmail.co.uk', + 'user@live.co.uk', + 'user@yahoo.com', + 'user@yahoo.co.uk', + 'user@yahoo.ca', + 'user@ymail.com', + 'user@rocketmail.com', + 'user@icloud.com', + 'user@me.com', + 'user@mac.com', + 'user@protonmail.com', + 'user@proton.me', + 'user@pm.me', + 'user@fastmail.com', + 'user@fastmail.fm', + ]; + + foreach ($supportedEmails as $emailAddress) { + $email = new Email($emailAddress); + $this->assertTrue($email->isCanonicalSupported(), "Email {$emailAddress} should support normalization"); + } + + $unsupportedEmails = [ + 'user@example.com', + 'user@test.org', + 'user@company.net', + 'user@business.co.uk', + ]; + + foreach ($unsupportedEmails as $emailAddress) { + $email = new Email($emailAddress); + $this->assertFalse($email->isCanonicalSupported(), "Email {$emailAddress} should not support normalization"); + } + } + + public function test_get_canonical_domain(): void + { + $testCases = [ + ['user@gmail.com', 'gmail.com'], + ['user@googlemail.com', 'gmail.com'], + ['user@outlook.com', 'outlook.com'], + ['user@hotmail.com', 'outlook.com'], + ['user@live.com', 'outlook.com'], + ['user@outlook.co.uk', 'outlook.com'], + ['user@hotmail.co.uk', 'outlook.com'], + ['user@live.co.uk', 'outlook.com'], + ['user@yahoo.com', 'yahoo.com'], + ['user@yahoo.co.uk', 'yahoo.com'], + ['user@yahoo.ca', 'yahoo.com'], + ['user@ymail.com', 'yahoo.com'], + ['user@rocketmail.com', 'yahoo.com'], + ['user@icloud.com', 'icloud.com'], + ['user@me.com', 'icloud.com'], + ['user@mac.com', 'icloud.com'], + ['user@protonmail.com', 'protonmail.com'], + ['user@proton.me', 'protonmail.com'], + ['user@pm.me', 'protonmail.com'], + ['user@fastmail.com', 'fastmail.com'], + ['user@fastmail.fm', 'fastmail.com'], + ['user@example.com', null], + ['user@test.org', null], + ['user@company.net', null], + ['user@business.co.uk', null], + ]; + + foreach ($testCases as [$emailAddress, $expectedCanonical]) { + $email = new Email($emailAddress); + $this->assertEquals($expectedCanonical, $email->getCanonicalDomain(), "Failed for email: {$emailAddress}"); + } + } + + public function test_get_unique_with_different_providers(): void + { + // Test that different providers are used correctly + $gmailEmail = new Email('user.name+tag@gmail.com'); + $this->assertEquals('username@gmail.com', $gmailEmail->getCanonical()); + + // TODO: Commented out until manual confirmation of Outlook's plus addressing support + // $outlookEmail = new Email('user.name+tag@outlook.com'); + // $this->assertEquals('user.name@outlook.com', $outlookEmail->getCanonical()); + + // Dots are preserved for Outlook + $outlookEmail = new Email('user.name@outlook.com'); + $this->assertEquals('user.name@outlook.com', $outlookEmail->getCanonical()); + + // TODO: Commented out until manual confirmation of non-Gmail providers' plus addressing and dots support + // $yahooEmail = new Email('user-name+tag@yahoo.com'); + // $this->assertEquals('username@yahoo.com', $yahooEmail->getCanonical()); + + // $genericEmail = new Email('user.name+tag@example.com'); + // $this->assertEquals('username@example.com', $genericEmail->getCanonical()); + + // Dots and pluses are preserved for non-Gmail providers + $yahooEmail = new Email('user-name@yahoo.com'); + $this->assertEquals('user-name@yahoo.com', $yahooEmail->getCanonical()); + + $genericEmail = new Email('user.name@example.com'); + $this->assertEquals('user.name@example.com', $genericEmail->getCanonical()); + } +} diff --git a/tests/Validator/EmailCorporateTest.php b/tests/Validator/EmailCorporateTest.php new file mode 100644 index 0000000..aca44f4 --- /dev/null +++ b/tests/Validator/EmailCorporateTest.php @@ -0,0 +1,110 @@ +assertEquals(true, $validator->isValid('test@company.com')); + $this->assertEquals(true, $validator->isValid('user@business.org')); + $this->assertEquals(true, $validator->isValid('user@enterprise.net')); + $this->assertEquals(true, $validator->isValid('user@corporation.co.uk')); + $this->assertEquals(true, $validator->isValid('user@organization.org')); + $this->assertEquals(true, $validator->isValid('user@firm.com')); + $this->assertEquals(true, $validator->isValid('user@office.net')); + $this->assertEquals(true, $validator->isValid('user@work.org')); + } + + public function test_invalid_free_email(): void + { + $validator = new EmailCorporate; + + $this->assertEquals(false, $validator->isValid('user@gmail.com')); + $this->assertEquals(false, $validator->isValid('user@yahoo.com')); + $this->assertEquals(false, $validator->isValid('user@hotmail.com')); + $this->assertEquals(false, $validator->isValid('user@outlook.com')); + $this->assertEquals(false, $validator->isValid('user@live.com')); + $this->assertEquals(false, $validator->isValid('user@aol.com')); + $this->assertEquals(false, $validator->isValid('user@icloud.com')); + $this->assertEquals(false, $validator->isValid('user@protonmail.com')); + $this->assertEquals(false, $validator->isValid('user@zoho.com')); + $this->assertEquals(false, $validator->isValid('user@yandex.com')); + $this->assertEquals(false, $validator->isValid('user@mail.com')); + $this->assertEquals(false, $validator->isValid('user@gmx.com')); + $this->assertEquals(false, $validator->isValid('user@web.de')); + $this->assertEquals(false, $validator->isValid('user@tutanota.com')); + $this->assertEquals(false, $validator->isValid('user@fastmail.com')); + $this->assertEquals(false, $validator->isValid('user@hey.com')); + } + + public function test_invalid_disposable_email(): void + { + $validator = new EmailCorporate; + + $this->assertEquals(false, $validator->isValid('user@10minutemail.com')); + $this->assertEquals(false, $validator->isValid('user@tempmail.org')); + $this->assertEquals(false, $validator->isValid('user@guerrillamail.com')); + $this->assertEquals(false, $validator->isValid('user@mailinator.com')); + $this->assertEquals(false, $validator->isValid('user@yopmail.com')); + $this->assertEquals(false, $validator->isValid('user@temp-mail.org')); + $this->assertEquals(false, $validator->isValid('user@throwaway.email')); + $this->assertEquals(false, $validator->isValid('user@getnada.com')); + $this->assertEquals(false, $validator->isValid('user@maildrop.cc')); + $this->assertEquals(false, $validator->isValid('user@sharklasers.com')); + $this->assertEquals(false, $validator->isValid('user@test.com')); + // company.org is corporate + $this->assertEquals(true, $validator->isValid('user@company.org')); + $this->assertEquals(true, $validator->isValid('user@business.org')); + $this->assertEquals(true, $validator->isValid('user@enterprise.net')); + } + + public function test_invalid_email_format(): void + { + $validator = new EmailCorporate; + + $this->assertEquals(false, $validator->isValid('')); + $this->assertEquals(false, $validator->isValid('invalid-email')); + $this->assertEquals(false, $validator->isValid('user@example@com')); + $this->assertEquals(false, $validator->isValid('@example.com')); + $this->assertEquals(false, $validator->isValid('user@')); + } + + public function test_non_string_input(): void + { + $validator = new EmailCorporate; + + $this->assertEquals(false, $validator->isValid(null)); + $this->assertEquals(false, $validator->isValid(123)); + $this->assertEquals(false, $validator->isValid([])); + $this->assertEquals(false, $validator->isValid(new \stdClass)); + $this->assertEquals(false, $validator->isValid(true)); + $this->assertEquals(false, $validator->isValid(false)); + } + + public function test_validatordescription(): void + { + $validator = new EmailCorporate; + + $this->assertEquals('Value must be a valid email address from a corporate domain', $validator->getDescription()); + } + + public function test_validatortype(): void + { + $validator = new EmailCorporate; + + $this->assertEquals('string', $validator->getType()); + } + + public function test_validator_is_array(): void + { + $validator = new EmailCorporate; + + $this->assertEquals(false, $validator->isArray()); + } +} diff --git a/tests/Validator/EmailDomainTest.php b/tests/Validator/EmailDomainTest.php new file mode 100644 index 0000000..8d6c970 --- /dev/null +++ b/tests/Validator/EmailDomainTest.php @@ -0,0 +1,70 @@ +assertEquals(true, $validator->isValid('test@example.com')); + $this->assertEquals(true, $validator->isValid('user@mail.example.com')); + $this->assertEquals(true, $validator->isValid('user@mail.sub.example.com')); + $this->assertEquals(true, $validator->isValid('user@example-domain.com')); + $this->assertEquals(true, $validator->isValid('user@example123.com')); + } + + public function test_invalid_email_domain(): void + { + $validator = new EmailDomain; + + $this->assertEquals(false, $validator->isValid('')); + $this->assertEquals(false, $validator->isValid('invalid-email')); + $this->assertEquals(false, $validator->isValid('user@example..com')); + // filter_var allows consecutive hyphens, so this will be valid + $this->assertEquals(true, $validator->isValid('user@example--com.com')); + $this->assertEquals(false, $validator->isValid('user@.example.com')); + $this->assertEquals(false, $validator->isValid('user@example.com.')); + $this->assertEquals(false, $validator->isValid('user@-example.com')); + $this->assertEquals(false, $validator->isValid('user@example-.com')); + $this->assertEquals(false, $validator->isValid('user@example')); + $this->assertEquals(false, $validator->isValid('user@example!.com')); + } + + public function test_non_string_input(): void + { + $validator = new EmailDomain; + + $this->assertEquals(false, $validator->isValid(null)); + $this->assertEquals(false, $validator->isValid(123)); + $this->assertEquals(false, $validator->isValid([])); + $this->assertEquals(false, $validator->isValid(new \stdClass)); + $this->assertEquals(false, $validator->isValid(true)); + $this->assertEquals(false, $validator->isValid(false)); + } + + public function test_validatordescription(): void + { + $validator = new EmailDomain; + + $this->assertEquals('Value must be a valid email address with a valid domain', $validator->getDescription()); + } + + public function test_validatortype(): void + { + $validator = new EmailDomain; + + $this->assertEquals('string', $validator->getType()); + } + + public function test_validator_is_array(): void + { + $validator = new EmailDomain; + + $this->assertEquals(false, $validator->isArray()); + } +} diff --git a/tests/Validator/EmailLocalTest.php b/tests/Validator/EmailLocalTest.php new file mode 100644 index 0000000..7654f71 --- /dev/null +++ b/tests/Validator/EmailLocalTest.php @@ -0,0 +1,66 @@ +assertEquals(true, $validator->isValid('test@example.com')); + $this->assertEquals(true, $validator->isValid('user.name+tag@example.com')); + $this->assertEquals(true, $validator->isValid('user-name@example.com')); + $this->assertEquals(true, $validator->isValid('user_name@example.com')); + $this->assertEquals(true, $validator->isValid('user123@example.com')); + $this->assertEquals(true, $validator->isValid('user.name.last@example.com')); + } + + public function test_invalid_email_local(): void + { + $validator = new EmailLocal; + + $this->assertEquals(false, $validator->isValid('')); + $this->assertEquals(false, $validator->isValid('invalid-email')); + $this->assertEquals(false, $validator->isValid('user..name@example.com')); + $this->assertEquals(false, $validator->isValid('.user@example.com')); + $this->assertEquals(false, $validator->isValid('user.@example.com')); + $this->assertEquals(false, $validator->isValid('user!@example.com')); + } + + public function test_non_string_input(): void + { + $validator = new EmailLocal; + + $this->assertEquals(false, $validator->isValid(null)); + $this->assertEquals(false, $validator->isValid(123)); + $this->assertEquals(false, $validator->isValid([])); + $this->assertEquals(false, $validator->isValid(new \stdClass)); + $this->assertEquals(false, $validator->isValid(true)); + $this->assertEquals(false, $validator->isValid(false)); + } + + public function test_validatordescription(): void + { + $validator = new EmailLocal; + + $this->assertEquals('Value must be a valid email address with a valid local part', $validator->getDescription()); + } + + public function test_validatortype(): void + { + $validator = new EmailLocal; + + $this->assertEquals('string', $validator->getType()); + } + + public function test_validator_is_array(): void + { + $validator = new EmailLocal; + + $this->assertEquals(false, $validator->isArray()); + } +} diff --git a/tests/Validator/EmailNotDisposableTest.php b/tests/Validator/EmailNotDisposableTest.php new file mode 100644 index 0000000..19d2649 --- /dev/null +++ b/tests/Validator/EmailNotDisposableTest.php @@ -0,0 +1,85 @@ +assertEquals(true, $validator->isValid('test@company.org')); + $this->assertEquals(true, $validator->isValid('user@gmail.com')); + $this->assertEquals(true, $validator->isValid('user@yahoo.com')); + $this->assertEquals(true, $validator->isValid('user@company.com')); + $this->assertEquals(true, $validator->isValid('user@business.org')); + } + + public function test_invalid_disposable_email(): void + { + $validator = new EmailNotDisposable; + + $this->assertEquals(false, $validator->isValid('user@10minutemail.com')); + $this->assertEquals(false, $validator->isValid('user@tempmail.org')); + $this->assertEquals(false, $validator->isValid('user@guerrillamail.com')); + $this->assertEquals(false, $validator->isValid('user@mailinator.com')); + $this->assertEquals(false, $validator->isValid('user@yopmail.com')); + $this->assertEquals(false, $validator->isValid('user@temp-mail.org')); + $this->assertEquals(false, $validator->isValid('user@throwaway.email')); + $this->assertEquals(false, $validator->isValid('user@getnada.com')); + $this->assertEquals(false, $validator->isValid('user@maildrop.cc')); + $this->assertEquals(false, $validator->isValid('user@sharklasers.com')); + $this->assertEquals(false, $validator->isValid('user@test.com')); + // company.org is not disposable + $this->assertEquals(true, $validator->isValid('user@company.org')); + $this->assertEquals(true, $validator->isValid('user@business.org')); + $this->assertEquals(true, $validator->isValid('user@enterprise.net')); + } + + public function test_invalid_email_format(): void + { + $validator = new EmailNotDisposable; + + $this->assertEquals(false, $validator->isValid('')); + $this->assertEquals(false, $validator->isValid('invalid-email')); + $this->assertEquals(false, $validator->isValid('user@example@com')); + $this->assertEquals(false, $validator->isValid('@example.com')); + $this->assertEquals(false, $validator->isValid('user@')); + } + + public function test_non_string_input(): void + { + $validator = new EmailNotDisposable; + + $this->assertEquals(false, $validator->isValid(null)); + $this->assertEquals(false, $validator->isValid(123)); + $this->assertEquals(false, $validator->isValid([])); + $this->assertEquals(false, $validator->isValid(new \stdClass)); + $this->assertEquals(false, $validator->isValid(true)); + $this->assertEquals(false, $validator->isValid(false)); + } + + public function test_validatordescription(): void + { + $validator = new EmailNotDisposable; + + $this->assertEquals('Value must be a valid email address that is not from a disposable email service', $validator->getDescription()); + } + + public function test_validatortype(): void + { + $validator = new EmailNotDisposable; + + $this->assertEquals('string', $validator->getType()); + } + + public function test_validator_is_array(): void + { + $validator = new EmailNotDisposable; + + $this->assertEquals(false, $validator->isArray()); + } +} diff --git a/tests/Validator/EmailTest.php b/tests/Validator/EmailTest.php new file mode 100644 index 0000000..f88dfea --- /dev/null +++ b/tests/Validator/EmailTest.php @@ -0,0 +1,81 @@ +assertEquals(true, $validator->isValid('test@example.com')); + $this->assertEquals(true, $validator->isValid('user.name+tag@example.com')); + $this->assertEquals(true, $validator->isValid('user-name@example-domain.com')); + $this->assertEquals(true, $validator->isValid('user_name@example.com')); + $this->assertEquals(true, $validator->isValid('user123@example123.com')); + $this->assertEquals(true, $validator->isValid('user.name.last@example.com')); + $this->assertEquals(true, $validator->isValid('user@mail.example.com')); + $this->assertEquals(true, $validator->isValid('user@mail.sub.example.com')); + } + + public function test_invalidemail(): void + { + $validator = new Email; + + $this->assertEquals(false, $validator->isValid('')); + $this->assertEquals(false, $validator->isValid('invalid-email')); + $this->assertEquals(false, $validator->isValid('user@example@com')); + $this->assertEquals(false, $validator->isValid('@example.com')); + $this->assertEquals(false, $validator->isValid('user@')); + $this->assertEquals(false, $validator->isValid('user..name@example.com')); + $this->assertEquals(false, $validator->isValid('.user@example.com')); + $this->assertEquals(false, $validator->isValid('user.@example.com')); + $this->assertEquals(false, $validator->isValid('user@example..com')); + // filter_var allows consecutive hyphens, so this will be valid + $this->assertEquals(true, $validator->isValid('user@example--com.com')); + $this->assertEquals(false, $validator->isValid('user@.example.com')); + $this->assertEquals(false, $validator->isValid('user@example.com.')); + $this->assertEquals(false, $validator->isValid('user@-example.com')); + $this->assertEquals(false, $validator->isValid('user@example-.com')); + $this->assertEquals(false, $validator->isValid('user@example')); + $this->assertEquals(false, $validator->isValid('user@example!.com')); + // filter_var allows exclamation marks in local part, so this will be valid + $this->assertEquals(true, $validator->isValid('user!@example.com')); + } + + public function test_non_string_input(): void + { + $validator = new Email; + + $this->assertEquals(false, $validator->isValid(null)); + $this->assertEquals(false, $validator->isValid(123)); + $this->assertEquals(false, $validator->isValid([])); + $this->assertEquals(false, $validator->isValid(new \stdClass)); + $this->assertEquals(false, $validator->isValid(true)); + $this->assertEquals(false, $validator->isValid(false)); + } + + public function test_validatordescription(): void + { + $validator = new Email; + + $this->assertEquals('Value must be a valid email address', $validator->getDescription()); + } + + public function test_validatortype(): void + { + $validator = new Email; + + $this->assertEquals('string', $validator->getType()); + } + + public function test_validator_is_array(): void + { + $validator = new Email; + + $this->assertEquals(false, $validator->isArray()); + } +}