presseportale/app/Console/Commands/CheckMailDomains.php
Kevin Adametz ada41cb067 mail: Kontakterreichbarkeit - Formpruefung, Domainpruefung, Einlieferer-Bericht
Vier Punkte aus kontakterreichbarkeit-mx-bounces-2026-09-15.md, ohne
Veroeffentlichungssperre:

- PlausibleEmailDomain: Domain ohne Endung oder mit Tippfehler-TLD ist
  ein 422 an der Legacy-API, deterministisch ohne DNS.
- email_domain_checks + MailDomainChecker ueber DnsResolver-Schnittstelle
  (RFC 5321 impliziter MX, RFC 7505 Null MX, Unresolved ist kein Befund),
  Observer an Company/Contact, Job nach Commit, taeglicher Refresh,
  mail:check-domains fuer den Bestand. Nie eine Adresssperre aus DNS.
- Admin > Reports > Einlieferer-Bericht: tote Adressen aus Sperrliste
  und Domainbefund, automatisch abgelehnte Meldungen mit Grund,
  Text zum Kopieren und CSV. Versendet nichts.
- EmailBounceKind::Policy: 5.7.x wird protokolliert, zaehlt aber nicht
  auf den Schwellwert. Statuscode entscheidet vor Diagnosetext.

Messung vorab: 6 von 877 API-Domains ohne Zustellweg, eine davon fuer
66 der 151 Ruecklaeufer verantwortlich.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:22:39 +00:00

114 lines
4.1 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Company;
use App\Models\Contact;
use App\Models\EmailDomainCheck;
use App\Rules\PlausibleEmailDomain;
use App\Services\Mail\MailDomainChecker;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
/**
* Prüft die Mail-Domains des Bestands (Kontakterreichbarkeit, Abschnitt 7).
*
* Zwei Aufgaben: einmalig den Bestand aufnehmen (`--months=` grenzt auf
* Firmen mit jüngeren Meldungen ein) und danach täglich die abgelaufenen
* Befunde erneuern. Der Lauf bewertet nichts und sperrt nichts — er hält
* fest, was das DNS sagt.
*/
class CheckMailDomains extends Command
{
protected $signature = 'mail:check-domains
{--months= : Nur Firmen mit Meldungen aus den letzten N Monaten}
{--limit=500 : Höchstens so viele Domains je Lauf}
{--refresh : Auch gültige Befunde neu prüfen}
{--stale : Nur abgelaufene Befunde erneuern (Scheduler)}';
protected $description = 'Prüft die DNS-Zustellwege der hinterlegten E-Mail-Domains.';
public function handle(MailDomainChecker $checker): int
{
$limit = max(1, (int) $this->option('limit'));
$domains = $this->option('stale') ? $this->staleDomains($limit) : $this->domainsFromContacts($limit);
if ($domains->isEmpty()) {
$this->info('Nichts zu prüfen.');
return self::SUCCESS;
}
$counts = [];
$bar = $this->output->createProgressBar($domains->count());
foreach ($domains as $domain) {
$check = $checker->check($domain, force: (bool) $this->option('refresh') || (bool) $this->option('stale'));
$counts[$check->state->value] = ($counts[$check->state->value] ?? 0) + 1;
$bar->advance();
}
$bar->finish();
$this->newLine(2);
foreach ($counts as $state => $count) {
$this->line(sprintf(' %-16s %d', $state, $count));
}
$broken = EmailDomainCheck::query()->broken()->count();
$this->info(sprintf('%d Domains geprüft, %d ohne Zustellweg im Bestand.', $domains->count(), $broken));
return self::SUCCESS;
}
/**
* @return Collection<int, string>
*/
private function staleDomains(int $limit): Collection
{
return EmailDomainCheck::query()
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '<=', now()))
->orderBy('expires_at')
->limit($limit)
->pluck('domain');
}
/**
* @return Collection<int, string>
*/
private function domainsFromContacts(int $limit): Collection
{
$months = $this->option('months') !== null ? max(1, (int) $this->option('months')) : null;
$companies = Company::query()
->withoutGlobalScopes()
->whereNotNull('email')
->where('email', '!=', '')
->when($months !== null, fn ($q) => $q->whereHas('pressReleases', fn ($r) => $r->withoutGlobalScopes()->where('press_releases.created_at', '>=', now()->subMonths($months))))
->pluck('email');
$contacts = Contact::query()
->withoutGlobalScopes()
->whereNotNull('email')
->where('email', '!=', '')
->when($months !== null, fn ($q) => $q->whereHas('company', fn ($c) => $c->withoutGlobalScopes()->whereHas('pressReleases', fn ($r) => $r->withoutGlobalScopes()->where('press_releases.created_at', '>=', now()->subMonths($months)))))
->pluck('email');
$known = EmailDomainCheck::query()
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->pluck('domain')
->flip();
$domains = [];
foreach ($companies->merge($contacts) as $email) {
$domain = PlausibleEmailDomain::domainOf($email);
if ($domain !== null && ! isset($known[$domain])) {
$domains[$domain] = true;
}
}
return collect(array_keys($domains))->take($limit)->values();
}
}