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+ }
0 commit comments