Skip to content

Commit 2ecbb4b

Browse files
disposable email utility added
1 parent 0427629 commit 2ecbb4b

File tree

3 files changed

+264
-2
lines changed

3 files changed

+264
-2
lines changed
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tamedevelopers\Support\Traits;
6+
7+
use Tamedevelopers\Support\Str;
8+
use Tamedevelopers\Support\Env;
9+
use Tamedevelopers\Support\Server;
10+
use Tamedevelopers\Support\Capsule\File;
11+
12+
/**
13+
* Utility for fetching, caching, and checking disposable email domains.
14+
*
15+
* Caching strategy:
16+
* - First attempt to read a gzip-compressed JSON cache: storage/cache/disposable_domains.json.gz
17+
* - If missing or expired, fetch the latest domains.json from upstream, normalize, compress and store.
18+
* - TTL (in days) can be configured via DISPOSABLE_DOMAINS_TTL_DAYS (default: 7 days).
19+
* - Upstream URL can be overridden via DISPOSABLE_DOMAINS_URL.
20+
*/
21+
trait DisposableEmailUtilityTrait
22+
{
23+
/** @var string Default upstream JSON URL */
24+
private static string $REMOTE_JSON_URL = 'https://disposable.github.io/disposable-email-domains/domains.json';
25+
26+
/** @var array<string>|null In-memory normalized domain list (lowercased, unique, sorted) */
27+
private static ?array $disposableDomains = null;
28+
29+
/** @var array<string,bool>|null Fast lookup index (built from $disposableDomains) */
30+
private static ?array $domainsIndex = null;
31+
32+
/** @var int Days for cached expiration */
33+
private static ?int $cachedExpireDay = 7;
34+
35+
/**
36+
* Check if an email address belongs to a disposable provider.
37+
*/
38+
public static function isDisposableEmail(string $email): bool
39+
{
40+
$email = Str::lower($email);
41+
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
42+
return false;
43+
}
44+
45+
$domain = substr(strrchr($email, '@') ?: '', 1);
46+
return self::isDisposableDomain($domain);
47+
}
48+
49+
/**
50+
* Check if a domain (or any of its parent suffixes) is disposable.
51+
*/
52+
public static function isDisposableDomain(string $domain): bool
53+
{
54+
$domain = Str::lower($domain);
55+
if ($domain === '') {
56+
return false;
57+
}
58+
59+
$domains = self::domains();
60+
61+
// Build or reuse index for O(1) lookups
62+
if (self::$domainsIndex === null) {
63+
self::$domainsIndex = array_fill_keys($domains, true);
64+
}
65+
66+
// Exact domain match
67+
if (isset(self::$domainsIndex[$domain])) {
68+
return true;
69+
}
70+
71+
// Check parent suffixes (e.g., sub.mail.temp-mail.org -> temp-mail.org)
72+
$parts = explode('.', $domain);
73+
array_shift($parts); // remove leftmost label
74+
while (count($parts) >= 2) {
75+
$candidate = implode('.', $parts);
76+
if (isset(self::$domainsIndex[$candidate])) {
77+
return true;
78+
}
79+
array_shift($parts);
80+
}
81+
82+
return false;
83+
}
84+
85+
/**
86+
* Get the cached/remote list of disposable domains.
87+
* Set $forceRefresh to true to ignore cache and fetch latest immediately.
88+
*
89+
* @return array<int,string>
90+
*/
91+
public static function domains(bool $forceRefresh = false): array
92+
{
93+
if (!$forceRefresh && is_array(self::$disposableDomains) && self::$disposableDomains !== []) {
94+
return self::$disposableDomains;
95+
}
96+
97+
$data = $forceRefresh ? null : self::loadFromCache();
98+
if ($data === null) {
99+
$data = self::fetchAndCache();
100+
}
101+
102+
self::$disposableDomains = $data;
103+
self::$domainsIndex = null; // reset index so it's rebuilt lazily
104+
return self::$disposableDomains;
105+
}
106+
107+
/**
108+
* Force-refresh the cache from the upstream source and return the domains.
109+
*/
110+
public static function refreshCache(): array
111+
{
112+
return self::domains(true);
113+
}
114+
115+
/**
116+
* Cache metadata for debugging/monitoring purposes.
117+
*/
118+
public static function cacheInfo(): array
119+
{
120+
$file = self::cacheFile();
121+
$ttl = self::cacheTtlSeconds();
122+
$exists = File::exists($file);
123+
$last = $exists ? (int) File::lastModified($file) : null;
124+
125+
return [
126+
'path' => $file,
127+
'exists' => $exists,
128+
'last_modified' => $last,
129+
'expires_at' => $last ? $last + $ttl : null,
130+
'ttl_seconds' => $ttl,
131+
];
132+
}
133+
134+
// ========== Internals ==========
135+
136+
/**
137+
* Fetch domains.json, normalize, write cache (gzip), and return the list.
138+
*
139+
* @return array<int,string>
140+
*/
141+
private static function fetchAndCache(): array
142+
{
143+
$url = Env::env('DISPOSABLE_DOMAINS_URL', self::$REMOTE_JSON_URL);
144+
$json = File::get($url);
145+
146+
// Fallback to existing cache when fetch fails
147+
if ($json === false || $json === '') {
148+
return self::loadFromCache() ?? [];
149+
}
150+
151+
$data = json_decode($json, true);
152+
if (!is_array($data)) {
153+
return self::loadFromCache() ?? [];
154+
}
155+
156+
$domains = self::normalizeDomainsArray($data);
157+
self::writeCache($domains);
158+
return $domains;
159+
}
160+
161+
/**
162+
* Try to load domains from the local gzip cache if present and not expired.
163+
*
164+
* @return array<int,string>|null Null when cache is missing/expired/unreadable
165+
*/
166+
private static function loadFromCache(): ?array
167+
{
168+
$file = self::cacheFile();
169+
$ttl = self::cacheTtlSeconds();
170+
171+
if (!File::exists($file)) {
172+
return null;
173+
}
174+
175+
$modified = (int) File::lastModified($file);
176+
if ($modified <= 0 || (time() - $modified) > $ttl) {
177+
return null; // expired
178+
}
179+
180+
$raw = File::get($file);
181+
if ($raw === false || $raw === '') {
182+
return null;
183+
}
184+
185+
// Prefer gzip; fall back to plain JSON if not compressed
186+
$json = function_exists('gzdecode') ? @gzdecode($raw) : null;
187+
if ($json === false || $json === null) {
188+
$json = $raw; // assume plain JSON
189+
}
190+
191+
$data = json_decode((string) $json, true);
192+
if (!is_array($data)) {
193+
return null;
194+
}
195+
196+
return self::normalizeDomainsArray($data);
197+
}
198+
199+
/**
200+
* Normalize domain array: lower-case, trim, unique, sorted.
201+
*
202+
* @param array<int,mixed> $data
203+
* @return array<int,string>
204+
*/
205+
private static function normalizeDomainsArray(array $data): array
206+
{
207+
$set = [];
208+
foreach ($data as $d) {
209+
if (!is_string($d)) {
210+
continue;
211+
}
212+
$d = Str::lower($d);
213+
if ($d !== '') {
214+
$set[$d] = true;
215+
}
216+
}
217+
$domains = array_keys($set);
218+
sort($domains, SORT_STRING);
219+
return $domains;
220+
}
221+
222+
/**
223+
* Write gzip-compressed JSON cache to disk.
224+
*/
225+
private static function writeCache(array $domains): void
226+
{
227+
$dir = self::cacheDir();
228+
File::makeDirectory($dir);
229+
230+
if (!File::isDirectory($dir)) {
231+
return; // cannot write
232+
}
233+
234+
$payload = json_encode(array_values($domains), JSON_UNESCAPED_SLASHES);
235+
$compressed = function_exists('gzencode') ? gzencode((string) $payload, 9) : $payload;
236+
File::put(self::cacheFile(), (string) $compressed);
237+
}
238+
239+
private static function cacheDir(): string
240+
{
241+
return Server::formatWithBaseDirectory('storage/cache');
242+
}
243+
244+
private static function cacheFile(): string
245+
{
246+
return rtrim(self::cacheDir(), '/') . '/disposable_domains.json.gz';
247+
}
248+
249+
private static function cacheTtlSeconds(): int
250+
{
251+
$days = (int) Env::env('DISPOSABLE_DOMAINS_TTL_DAYS', self::$cachedExpireDay);
252+
$days = $days > 0 ? $days : self::$cachedExpireDay;
253+
return $days * 86400;
254+
}
255+
}

src/Utility.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
namespace Tamedevelopers\Support;
66

7-
use Tamedevelopers\Support\Traits\EmailUtilityTrait;
87
use Tamedevelopers\Support\Traits\TextUtilityTrait;
8+
use Tamedevelopers\Support\Traits\EmailUtilityTrait;
9+
use Tamedevelopers\Support\Traits\DisposableEmailUtilityTrait;
910

1011

1112
/**
@@ -29,7 +30,8 @@
2930
class Utility
3031
{
3132
use TextUtilityTrait,
32-
EmailUtilityTrait;
33+
EmailUtilityTrait,
34+
DisposableEmailUtilityTrait;
3335

3436
/**
3537
* Providers rules cache loaded from stubs/emailProviders.php

tests/utility.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
// echo($util) . PHP_EOL;
3535

3636
dump(
37+
38+
$util->cacheInfo(),
39+
$util->isDisposableEmail($email3),
40+
$util->isDisposableDomain($email3),
41+
3742
$util->maskEmail($email),
3843
[
3944
Utility::normalizeEmail($email),

0 commit comments

Comments
 (0)