Skip to content

Commit fd7f438

Browse files
committed
feat: optimize disposable email detection
- Add RFC, strict, DNS, spoof, filter, and Unicode validation modes - Optimize domain normalization, matching, blacklist, whitelist, and cache handling - Simplify Install, Sync, and Stats command classes - Improve remote URL parsing, response normalization, and file syncing - Add detailed result source and compatibility coverage - Expand validation, command, domain, parser, and facade tests - Split documentation into focused feature pages - Add Advanced RFC/DNS documentation and grouped VitePress sidebar
1 parent 31edb1e commit fd7f438

19 files changed

Lines changed: 821 additions & 0 deletions

src/Commands/Install.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace EragLaravelDisposableEmail\Commands;
4+
5+
use EragLaravelDisposableEmail\Support\Cache;
6+
use Illuminate\Console\Command;
7+
8+
class Install extends Command
9+
{
10+
protected $signature = 'erag:install-disposable-email';
11+
12+
protected $description = 'Publish config and initialize disposable domain file.';
13+
14+
public function handle(): void
15+
{
16+
Cache::clear();
17+
18+
$this->call('vendor:publish', [
19+
'--tag' => 'erag:publish-disposable-config',
20+
'--force' => true,
21+
]);
22+
23+
$this->info('✅ Disposable Email Package Installed Successfully!');
24+
}
25+
}

src/Commands/Stats.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace EragLaravelDisposableEmail\Commands;
4+
5+
use EragLaravelDisposableEmail\Support\Stats as StatsData;
6+
use Illuminate\Console\Command;
7+
8+
class Stats extends Command
9+
{
10+
protected $signature = 'disposable:stats';
11+
12+
protected $description = 'Show disposable email package domain and cache stats.';
13+
14+
public function handle(): void
15+
{
16+
$this->table(['Metric', 'Value'], (new StatsData)->rows());
17+
}
18+
}

src/Commands/Sync.php

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EragLaravelDisposableEmail\Commands;
6+
7+
use EragLaravelDisposableEmail\Support\Cache;
8+
use EragLaravelDisposableEmail\Support\ResponseParser;
9+
use EragLaravelDisposableEmail\Support\UrlList;
10+
use Illuminate\Console\Command;
11+
use Illuminate\Http\Client\Response;
12+
use Illuminate\Support\Facades\File;
13+
use Illuminate\Support\Facades\Http;
14+
use Throwable;
15+
16+
class Sync extends Command
17+
{
18+
protected $signature = 'erag:sync-disposable-email-list';
19+
20+
protected $description = 'Fetch and update the disposable email domains list';
21+
22+
public function handle(): int
23+
{
24+
Cache::clear();
25+
26+
$remoteUrls = $this->remoteUrls();
27+
$directory = config('disposable-email.blacklist_file');
28+
29+
if (! is_string($directory) || trim($directory) === '') {
30+
$this->error('Invalid disposable-email.blacklist_file config value.');
31+
32+
return self::FAILURE;
33+
}
34+
35+
if ($remoteUrls === []) {
36+
$this->error('No valid remote URLs configured in disposable-email.remote_url.');
37+
38+
return self::FAILURE;
39+
}
40+
41+
if (! $this->ensureDirectoryExists($directory)) {
42+
return self::FAILURE;
43+
}
44+
45+
$synced = 0;
46+
$failed = 0;
47+
48+
foreach ($remoteUrls as $url) {
49+
$result = $this->syncUrl($url, $directory);
50+
51+
if ($result) {
52+
$synced++;
53+
} else {
54+
$failed++;
55+
}
56+
}
57+
58+
$this->newLine();
59+
$this->info("Sync complete. Synced: {$synced}. Failed: {$failed}.");
60+
61+
return $synced > 0 ? self::SUCCESS : self::FAILURE;
62+
}
63+
64+
/**
65+
* @return array<int, string>
66+
*/
67+
protected function remoteUrls(): array
68+
{
69+
return UrlList::from(config('disposable-email.remote_url', []));
70+
}
71+
72+
protected function ensureDirectoryExists(string $directory): bool
73+
{
74+
try {
75+
if (! File::exists($directory)) {
76+
File::makeDirectory($directory, 0755, true);
77+
$this->info("Directory created at: {$directory}");
78+
}
79+
80+
return true;
81+
} catch (Throwable $exception) {
82+
$this->error("Unable to create blacklist directory [{$directory}]: {$exception->getMessage()}");
83+
84+
return false;
85+
}
86+
}
87+
88+
protected function syncUrl(string $url, string $directory): bool
89+
{
90+
$this->line("Fetching: {$url}");
91+
92+
try {
93+
$response = $this->fetch($url);
94+
} catch (Throwable $exception) {
95+
$this->error("Request failed for [{$url}]: {$exception->getMessage()}");
96+
97+
return false;
98+
}
99+
100+
if (! $response->successful()) {
101+
$this->error("Failed to fetch [{$url}]. HTTP status: {$response->status()}.");
102+
103+
return false;
104+
}
105+
106+
$domains = ResponseParser::parse($response->body());
107+
108+
if ($domains === []) {
109+
$this->warn("No valid domains found in [{$url}]. Skipping write.");
110+
111+
return false;
112+
}
113+
114+
$filePath = $directory.DIRECTORY_SEPARATOR.$this->filename($url);
115+
try {
116+
$this->write($filePath, $domains);
117+
} catch (Throwable $exception) {
118+
$this->error("Unable to write [{$filePath}]: {$exception->getMessage()}");
119+
120+
return false;
121+
}
122+
123+
$this->info('Saved '.number_format(count($domains))." domains to {$filePath}");
124+
125+
return true;
126+
}
127+
128+
protected function syncTimeout(): int
129+
{
130+
$timeout = config('disposable-email.sync_timeout', 30);
131+
132+
if (! is_numeric($timeout)) {
133+
return 30;
134+
}
135+
136+
$timeout = (int) $timeout;
137+
138+
return $timeout > 0 ? $timeout : 30;
139+
}
140+
141+
private function filename(string $url): string
142+
{
143+
$path = parse_url($url, PHP_URL_PATH);
144+
$name = is_string($path) ? pathinfo($path, PATHINFO_FILENAME) : '';
145+
$name = strtolower((string) preg_replace('/[^a-zA-Z0-9_-]+/', '-', $name));
146+
$name = trim($name, '-_');
147+
148+
return ($name === '' ? 'disposable-domains' : $name).'.txt';
149+
}
150+
151+
private function fetch(string $url): Response
152+
{
153+
return Http::timeout($this->syncTimeout())->retry(2, 500)->get($url);
154+
}
155+
156+
/**
157+
* @param array<int, string> $domains
158+
*/
159+
private function write(string $path, array $domains): void
160+
{
161+
File::put($path, implode(PHP_EOL, $domains).PHP_EOL);
162+
}
163+
}

src/Support/Cache.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EragLaravelDisposableEmail\Support;
6+
7+
use EragLaravelDisposableEmail\Enums\LaravelVersion;
8+
use Illuminate\Support\Facades\Cache as LaravelCache;
9+
10+
class Cache
11+
{
12+
public const PROVIDERS = 'erag-unauthorized-email-providers';
13+
14+
public const SOURCES = 'erag-unauthorized-email-provider-sources';
15+
16+
public static function remember(string $key, callable $callback): mixed
17+
{
18+
$ttl = (int) config('disposable-email.cache_ttl', 60);
19+
20+
if (version_compare(app()->version(), LaravelVersion::FLEXIBLE_CACHE->value, '>=')) {
21+
return LaravelCache::flexible($key, [$ttl / 2, $ttl * 2], $callback);
22+
}
23+
24+
return LaravelCache::remember($key, $ttl, $callback);
25+
}
26+
27+
public static function clear(): void
28+
{
29+
LaravelCache::forget(self::PROVIDERS);
30+
LaravelCache::forget(self::SOURCES);
31+
}
32+
}

src/Support/Checker.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EragLaravelDisposableEmail\Support;
6+
7+
class Checker
8+
{
9+
public function __construct(private readonly Domain $domains = new Domain) {}
10+
11+
public function check(string $emailOrDomain): DisposableEmailResult
12+
{
13+
$domain = Domain::extract($emailOrDomain);
14+
15+
if ($domain === '') {
16+
return new DisposableEmailResult(false, '');
17+
}
18+
19+
$blockSubdomains = (bool) config('disposable-email.block_subdomains', true);
20+
$whitelist = Matcher::find($domain, $this->domains->whitelist(), $blockSubdomains);
21+
22+
if ($whitelist !== null) {
23+
return new DisposableEmailResult(false, $domain, $whitelist, 'whitelist');
24+
}
25+
26+
$sources = $this->domains->sources();
27+
$matched = Matcher::find($domain, $sources, $blockSubdomains);
28+
29+
if ($matched === null) {
30+
return new DisposableEmailResult(false, $domain);
31+
}
32+
33+
return new DisposableEmailResult(true, $domain, $matched, $sources[$matched] ?? 'built-in');
34+
}
35+
36+
public function email(string $email): bool
37+
{
38+
return $this->check($email)->disposable();
39+
}
40+
41+
public function domain(string $emailOrDomain): bool
42+
{
43+
return $this->check($emailOrDomain)->disposable();
44+
}
45+
}

src/Support/Domain.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace EragLaravelDisposableEmail\Support;
6+
7+
class Domain
8+
{
9+
public function __construct(private readonly SourceMap $sources = new SourceMap) {}
10+
11+
public static function extract(string $emailOrDomain): string
12+
{
13+
$value = strtolower(trim($emailOrDomain));
14+
15+
if ($value === '') {
16+
return '';
17+
}
18+
19+
if (str_contains($value, '@')) {
20+
[, $value] = explode('@', $value, 2);
21+
}
22+
23+
return trim($value);
24+
}
25+
26+
public static function normalize(string $emailOrDomain): string
27+
{
28+
$domain = self::extract($emailOrDomain);
29+
30+
return self::isValid($domain) ? $domain : '';
31+
}
32+
33+
public static function isValid(string $domain): bool
34+
{
35+
return preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $domain) === 1;
36+
}
37+
38+
/**
39+
* @return array<string, string>
40+
*/
41+
public function sources(): array
42+
{
43+
if (config('disposable-email.cache_enabled', false)) {
44+
return Cache::remember(Cache::SOURCES, fn (): array => $this->sources->all());
45+
}
46+
47+
return $this->sources->all();
48+
}
49+
50+
/**
51+
* @return array<int, string>
52+
*/
53+
public function domains(): array
54+
{
55+
if (config('disposable-email.cache_enabled', false)) {
56+
return Cache::remember(Cache::PROVIDERS, fn (): array => array_keys($this->sources->all()));
57+
}
58+
59+
return array_keys($this->sources->all());
60+
}
61+
62+
/**
63+
* @return array<string, string>
64+
*/
65+
public function whitelist(): array
66+
{
67+
return $this->sources->whitelist();
68+
}
69+
70+
/**
71+
* @return array<string, string>
72+
*/
73+
public function custom(): array
74+
{
75+
return $this->sources->custom();
76+
}
77+
}

0 commit comments

Comments
 (0)