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>
178 lines
6.7 KiB
PHP
178 lines
6.7 KiB
PHP
<?php
|
|
|
|
use App\Enums\EmailBounceKind;
|
|
use App\Enums\EmailDomainState;
|
|
use App\Enums\EmailSuppressionReason;
|
|
use App\Enums\Portal;
|
|
use App\Enums\PressReleaseStatus;
|
|
use App\Models\Company;
|
|
use App\Models\Contact;
|
|
use App\Models\EmailDomainCheck;
|
|
use App\Models\PressRelease;
|
|
use App\Models\PressReleaseStatusLog;
|
|
use App\Models\User;
|
|
use App\Services\Mail\EmailSuppressionService;
|
|
use App\Services\Mail\SubmitterContactReport;
|
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Einlieferer-Bericht (Kontakterreichbarkeit, Abschnitt 7): was ein
|
|
* API-Einlieferer an seinen Daten reparieren muss.
|
|
*/
|
|
function submitterWithRelease(array $companyAttributes = [], ?string $rejectionReason = null): array
|
|
{
|
|
Queue::fake();
|
|
|
|
$user = User::factory()->create(['is_active' => true, 'portal' => Portal::Businessportal24]);
|
|
$company = Company::factory()->create(array_merge(['portal' => Portal::Businessportal24], $companyAttributes));
|
|
|
|
$release = PressRelease::factory()
|
|
->for($company)
|
|
->create([
|
|
'user_id' => $user->id,
|
|
'portal' => Portal::Businessportal24,
|
|
'status' => $rejectionReason !== null ? PressReleaseStatus::Rejected : PressReleaseStatus::Published,
|
|
]);
|
|
|
|
PressReleaseStatusLog::query()->create([
|
|
'press_release_id' => $release->id,
|
|
'from_status' => 'draft',
|
|
'to_status' => 'published',
|
|
'source' => 'api',
|
|
]);
|
|
|
|
if ($rejectionReason !== null) {
|
|
PressReleaseStatusLog::query()->create([
|
|
'press_release_id' => $release->id,
|
|
'from_status' => 'review',
|
|
'to_status' => 'rejected',
|
|
'source' => 'ki',
|
|
'reason' => $rejectionReason,
|
|
]);
|
|
}
|
|
|
|
return [$user, $company, $release];
|
|
}
|
|
|
|
test('the report lists api submitters with their volume', function () {
|
|
/** @var TestCase $this */
|
|
[$user] = submitterWithRelease();
|
|
|
|
$submitters = app(SubmitterContactReport::class)->submitters();
|
|
|
|
expect($submitters)->toHaveCount(1)
|
|
->and($submitters->first()->id)->toBe($user->id)
|
|
->and($submitters->first()->releases)->toBe(1);
|
|
});
|
|
|
|
test('a suppressed company address shows up as a broken contact', function () {
|
|
/** @var TestCase $this */
|
|
[$user, $company] = submitterWithRelease(['email' => 'presse@tot.example']);
|
|
app(EmailSuppressionService::class)->recordBounce('presse@tot.example', EmailBounceKind::Hard, 'imap', 'm1');
|
|
|
|
$rows = app(SubmitterContactReport::class)->brokenContacts($user);
|
|
|
|
expect($rows)->toHaveCount(1)
|
|
->and($rows->first()->email)->toBe('presse@tot.example')
|
|
->and($rows->first()->fromDomain)->toBeFalse()
|
|
->and($rows->first()->companyId)->toBe($company->id)
|
|
->and($rows->first()->releases)->toBe(1);
|
|
});
|
|
|
|
test('a domain without a mail path shows up even without any bounce', function () {
|
|
/** @var TestCase $this */
|
|
// Der Punkt der Vorabprüfung: sichtbar, bevor je eine Mail geschrieben wurde.
|
|
[$user] = submitterWithRelease(['email' => 'info@existiert-nicht.example']);
|
|
|
|
EmailDomainCheck::query()->create([
|
|
'domain' => 'existiert-nicht.example',
|
|
'state' => EmailDomainState::DomainMissing,
|
|
'detail' => 'Keine DNS-Einträge (NXDOMAIN)',
|
|
'checked_at' => now(),
|
|
'expires_at' => now()->addDay(),
|
|
]);
|
|
|
|
$rows = app(SubmitterContactReport::class)->brokenContacts($user);
|
|
|
|
expect($rows)->toHaveCount(1)
|
|
->and($rows->first()->fromDomain)->toBeTrue()
|
|
->and($rows->first()->reason)->toBe('Domain existiert nicht')
|
|
->and(app(EmailSuppressionService::class)->isSuppressed('info@existiert-nicht.example'))->toBeFalse();
|
|
});
|
|
|
|
test('an unresolved domain is no finding and stays out of the report', function () {
|
|
/** @var TestCase $this */
|
|
[$user] = submitterWithRelease(['email' => 'info@offen.example']);
|
|
|
|
EmailDomainCheck::query()->create([
|
|
'domain' => 'offen.example',
|
|
'state' => EmailDomainState::Unresolved,
|
|
'checked_at' => now(),
|
|
'expires_at' => now()->addMinutes(15),
|
|
]);
|
|
|
|
expect(app(SubmitterContactReport::class)->brokenContacts($user))->toBeEmpty();
|
|
});
|
|
|
|
test('press contacts of the submitter are covered, working addresses are not listed', function () {
|
|
/** @var TestCase $this */
|
|
[$user, $company] = submitterWithRelease(['email' => 'geht@example.com']);
|
|
Contact::factory()->for($company)->create(['email' => 'kaputt@example.com']);
|
|
|
|
app(EmailSuppressionService::class)->suppress('kaputt@example.com', EmailSuppressionReason::HardBounce, 'import');
|
|
|
|
$rows = app(SubmitterContactReport::class)->brokenContacts($user);
|
|
|
|
expect($rows)->toHaveCount(1)
|
|
->and($rows->first()->email)->toBe('kaputt@example.com');
|
|
});
|
|
|
|
test('automatic rejections are listed with their reason', function () {
|
|
/** @var TestCase $this */
|
|
[$user] = submitterWithRelease([], 'K1: Unzulässige Gesundheitswerbung');
|
|
|
|
$rejected = app(SubmitterContactReport::class)->rejectedReleases($user);
|
|
|
|
expect($rejected)->toHaveCount(1)
|
|
->and($rejected->first()->reason)->toBe('K1: Unzulässige Gesundheitswerbung');
|
|
});
|
|
|
|
test('policy rejections are reported but never suppress the address', function () {
|
|
/** @var TestCase $this */
|
|
[$user] = submitterWithRelease(['email' => 'presse@gefiltert.example']);
|
|
|
|
foreach (range(1, 4) as $i) {
|
|
app(EmailSuppressionService::class)->recordBounce(
|
|
'presse@gefiltert.example',
|
|
EmailBounceKind::Policy,
|
|
'imap',
|
|
'policy-'.$i,
|
|
['status' => '5.7.1', 'diagnostic' => '554 5.7.1 Relay access denied'],
|
|
);
|
|
}
|
|
|
|
$report = app(SubmitterContactReport::class);
|
|
|
|
expect($report->policyRejections($user))->toHaveCount(4)
|
|
->and(app(EmailSuppressionService::class)->isSuppressed('presse@gefiltert.example'))->toBeFalse()
|
|
->and($report->brokenContacts($user))->toBeEmpty();
|
|
});
|
|
|
|
test('the admin page renders the report for the submitter', function () {
|
|
/** @var TestCase $this */
|
|
test()->seed(RolesAndPermissionsSeeder::class);
|
|
[$submitter] = submitterWithRelease(['email' => 'presse@tot.example', 'name' => 'Tote Firma AG'], 'K4: Falsche Tatsachenbehauptung');
|
|
app(EmailSuppressionService::class)->recordBounce('presse@tot.example', EmailBounceKind::Hard, 'imap', 'm2');
|
|
|
|
$admin = User::factory()->create(['is_active' => true]);
|
|
$admin->assignRole('admin');
|
|
|
|
$this->actingAs($admin)
|
|
->get(route('admin.reports.submitter-contacts', ['submitter' => $submitter->id]))
|
|
->assertSuccessful()
|
|
->assertSeeText('presse@tot.example')
|
|
->assertSeeText('Tote Firma AG')
|
|
->assertSeeText('K4: Falsche Tatsachenbehauptung');
|
|
});
|