hymns / disposable-email
Refuse sign-ups from disposable (throwaway) email providers. Offline check, no dependencies.
Requires
- php: ^8.3
Requires (Dev)
- laravel/pint: ^1.18
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Refuse sign-ups from disposable (throwaway) email providers in PHP.
- About 9,900 domains from disposable-email-domains (CC0), shipped in the package.
- Subdomains are caught (
a@x.mailinator.com), lookalikes are not (a@acmemailinator.com). - Mainstream providers (Gmail, Outlook, Yahoo, iCloud, Proton, …) are never refused, even if a wrong entry turns up upstream.
- No network at check time and no dependencies. With opcache the list loads in about 0.04 ms per request and a check takes about 0.4 µs.
- Works with any framework: it takes a string.
- Optional refresh from cron, validated before use.
- PHP 8.3 or later.
composer require hymns/disposable-email
The PHP counterpart of go-disposable-email: same list, same rules.
Check an address
use Hymns\DisposableEmail\Disposable; use Hymns\DisposableEmail\DisposableEmailException; if (Disposable::isDisposable($email)) { // refuse } try { Disposable::check($email); } catch (DisposableEmailException $e) { // $e->getMessage(): // "disposable email addresses are not accepted; please use a permanent email" }
Neither one checks that the address is well formed; validate the format
first. examples/plain is a runnable sign-up
endpoint:
php -S localhost:8080 examples/plain/index.php
curl -i -d 'email=a@mailinator.com' localhost:8080/register # 400
Laravel
A closure rule is enough:
use Hymns\DisposableEmail\Disposable; use Hymns\DisposableEmail\DisposableEmailException; $request->validate([ 'email' => ['required', 'email', function (string $attribute, mixed $value, Closure $fail) { if (Disposable::isDisposable((string) $value)) { $fail(DisposableEmailException::MESSAGE); } }], ]);
Symfony
use Hymns\DisposableEmail\Disposable; use Hymns\DisposableEmail\DisposableEmailException; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; #[Assert\Email] public string $email; #[Assert\Callback] public function notDisposable(ExecutionContextInterface $context): void { if (Disposable::isDisposable($this->email)) { $context->buildViolation(DisposableEmailException::MESSAGE)->atPath('email')->addViolation(); } }
Your own rules
use Hymns\DisposableEmail\Checker; $checker = new Checker( blocked: ['new-throwaway.example'], // not listed upstream yet allowed: ['partner.mailinator.com'], // listed, but you trust it ); $checker->isDisposable($email);
The rules apply to subdomains too. At each level, from the full domain up,
a blocked domain refuses, an allowed one accepts, and a listed one refuses.
To give the static methods other rules, call
Disposable::setDefault(new Checker(...)) once at boot.
Keep the list current
The shipped list is as fresh as the version you installed. To follow upstream daily, run a refresh from your scheduler and load the stored list where your app boots:
use Hymns\DisposableEmail\FileStore; use Hymns\DisposableEmail\Refresher; $refresher = new Refresher(store: new FileStore('/var/lib/myapp/disposable.php')); // daily, from cron or your framework's scheduler $refresher->refresh(); // int domains in a new list, or null if unchanged // at boot, on every request $refresher->loadStored(); // uses the stored list, if there is one
In Laravel, the boot call goes in a service provider and the refresh in
routes/console.php:
// AppServiceProvider::boot() (new Refresher(store: new FileStore(storage_path('app/disposable.php'))))->loadStored(); // routes/console.php Schedule::call(fn () => (new Refresher(store: new FileStore(storage_path('app/disposable.php'))))->refresh()) ->daily();
refresh() sends If-None-Match, waits at most 30 seconds and reads at most
10 MB. A download is used only if it has at least 5,000 domains and every
line is a host name; otherwise it throws and the list in use stays.
examples/refresh.php is a cron-ready script.
FileStore writes a PHP file in one rename, so opcache holds the stored list
in shared memory just like the shipped one, and loadStored() costs about
as much as the default. Keep the file on local disk on each server, or on a
shared volume.
For a database or Redis, a Store is two methods. Cache get() if you call
it on every request, because parsing a text list takes a few milliseconds:
use Hymns\DisposableEmail\DomainList; use Hymns\DisposableEmail\Store; final class PdoStore implements Store { public function __construct(private PDO $db) {} public function get(): ?DomainList { $row = $this->db->query('SELECT domains, etag FROM email_blocklist WHERE id = 1')->fetch(); return $row ? DomainList::parse($row['domains'], $row['etag'] ?? '') : null; } public function put(DomainList $list): void { $this->db->prepare('REPLACE INTO email_blocklist (id, domains, etag) VALUES (1, ?, ?)') ->execute([$list->toText(), $list->etag]); } }
The default download uses PHP's stream wrapper (needs allow_url_fopen).
To go through Guzzle or your framework's HTTP client, implement Fetcher
and pass it as fetcher:.
Updating the shipped list
composer update-list
This downloads the upstream list, refuses it if it fails validation,
rewrites data/domains.php with the upstream commit in its header, and runs
the tests.
Design
The decisions and the reasons for them are in docs/adr/, and
the map is in docs/ARCHITECTURE.md.
Licence
The code is MIT. The shipped list (data/domains.php) is from
disposable-email-domains and is CC0 1.0.