- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt() für Sitemap-lastmod, dateModified und article:modified_time - R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen - R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt — hart wie weich — nur ein gültig signierter Rückläufer, unsignierte standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy) - R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und Unmerge, assignAll setzt veraltete Verweise zurück - R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach 395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert, Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst - N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap - OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
12 KiB
PHP
245 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Enums\ActivityType;
|
|
use App\Enums\EmailBounceKind;
|
|
use App\Enums\EmailSuppressionReason;
|
|
use App\Enums\Portal;
|
|
use App\Exceptions\MailDeliveryPausedException;
|
|
use App\Mail\MagicLoginLink;
|
|
use App\Mail\OperationalAlertMail;
|
|
use App\Mail\PressReleasePublished;
|
|
use App\Models\ActivityEvent;
|
|
use App\Models\Company;
|
|
use App\Models\Contact;
|
|
use App\Models\EmailBounce;
|
|
use App\Models\EmailSuppression;
|
|
use App\Models\PressRelease;
|
|
use App\Models\PressReleaseStatusLog;
|
|
use App\Models\User;
|
|
use App\Services\Mail\BounceReference;
|
|
use App\Services\Mail\EmailSuppressionService;
|
|
use App\Services\Mail\MailCircuitBreaker;
|
|
use App\Services\Mail\SmtpFailureClassifier;
|
|
use App\Services\PressRelease\PressReleaseService;
|
|
use Illuminate\Contracts\Queue\Job;
|
|
use Illuminate\Mail\SendQueuedMailable;
|
|
use Illuminate\Queue\Events\JobFailed;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\Mailer\Exception\TransportException;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Bounce-Suppression (Roadmap 1b.6): eine Liste, ein Gate vor jeder Mail,
|
|
* zwei Aufnahmepfade (SMTP-Ablehnung im Worker, Postfach) plus Import.
|
|
*/
|
|
function suppressionService(): EmailSuppressionService
|
|
{
|
|
return app(EmailSuppressionService::class);
|
|
}
|
|
|
|
function smtpJobFailed(string $message, ?string $recipient = null): void
|
|
{
|
|
$job = Mockery::mock(Job::class);
|
|
$job->shouldReceive('resolveName')->andReturn('Illuminate\\Mail\\SendQueuedMailable');
|
|
$job->shouldReceive('getQueue')->andReturn('default');
|
|
$job->shouldReceive('uuid')->andReturn((string) Str::uuid());
|
|
$job->shouldReceive('payload')->andReturn(['data' => ['command' => $recipient === null ? '' : serialize(
|
|
new SendQueuedMailable((new MagicLoginLink(User::factory()->create(['email' => $recipient]), 'https://x', '1'))->to($recipient))
|
|
)]]);
|
|
|
|
event(new JobFailed('database', $job, new TransportException($message)));
|
|
}
|
|
|
|
beforeEach(function (): void {
|
|
Cache::flush();
|
|
config()->set('monitoring.alerts.enabled', false);
|
|
});
|
|
|
|
// ------------------------------------------------- Zustand
|
|
|
|
test('a hard bounce suppresses immediately, soft bounces only from the third within the window', function () {
|
|
/** @var TestCase $this */
|
|
$service = suppressionService();
|
|
|
|
$service->recordBounce('Tot@Example.test', EmailBounceKind::Hard, 'imap', '<m1@mx>', ['status' => '5.1.1']);
|
|
expect($service->isSuppressed('tot@example.test'))->toBeTrue()
|
|
->and(EmailSuppression::query()->where('email', 'tot@example.test')->first()->reason)->toBe(EmailSuppressionReason::HardBounce);
|
|
|
|
$service->recordBounce('voll@example.test', EmailBounceKind::Soft, 'imap', '<s1@mx>', ['status' => '4.2.2']);
|
|
$service->recordBounce('voll@example.test', EmailBounceKind::Soft, 'imap', '<s2@mx>', ['status' => '4.2.2']);
|
|
expect($service->isSuppressed('voll@example.test'))->toBeFalse();
|
|
|
|
$service->recordBounce('voll@example.test', EmailBounceKind::Soft, 'imap', '<s3@mx>', ['status' => '4.2.2']);
|
|
expect($service->isSuppressed('voll@example.test'))->toBeTrue()
|
|
->and(EmailSuppression::query()->where('email', 'voll@example.test')->first()->reason)->toBe(EmailSuppressionReason::SoftBounces);
|
|
});
|
|
|
|
test('the same bounce message is counted once', function () {
|
|
/** @var TestCase $this */
|
|
$service = suppressionService();
|
|
|
|
$first = $service->recordBounce('voll@example.test', EmailBounceKind::Soft, 'imap', '<same@mx>');
|
|
$again = $service->recordBounce('voll@example.test', EmailBounceKind::Soft, 'imap', '<same@mx>');
|
|
|
|
expect($first)->not->toBeNull()
|
|
->and($again)->toBeNull()
|
|
->and(EmailBounce::query()->count())->toBe(1)
|
|
->and(EmailSuppression::query()->where('email', 'voll@example.test')->value('soft_bounce_count'))->toBe(1);
|
|
});
|
|
|
|
test('a release lifts the suppression until the next bounce', function () {
|
|
/** @var TestCase $this */
|
|
$service = suppressionService();
|
|
$service->recordBounce('tot@example.test', EmailBounceKind::Hard, 'imap', '<m1@mx>');
|
|
$admin = User::factory()->create();
|
|
|
|
$service->release(EmailSuppression::query()->firstOrFail(), $admin, 'Adresse repariert');
|
|
expect($service->isSuppressed('tot@example.test'))->toBeFalse();
|
|
|
|
$service->recordBounce('tot@example.test', EmailBounceKind::Hard, 'imap', '<m2@mx>');
|
|
expect($service->isSuppressed('tot@example.test'))->toBeTrue();
|
|
});
|
|
|
|
// ------------------------------------------------- Gate
|
|
|
|
test('the gate drops a mail to a suppressed address and logs it', function () {
|
|
/** @var TestCase $this */
|
|
suppressionService()->suppress('tot@example.test', EmailSuppressionReason::Manual, 'admin');
|
|
$user = User::factory()->create(['email' => 'tot@example.test']);
|
|
|
|
// Kein Mail::fake(): Das Gate hängt am echten Mailer (array-Transport).
|
|
Mail::to('tot@example.test')->send(new MagicLoginLink($user, 'https://x', '1'));
|
|
|
|
expect(app('mailer')->getSymfonyTransport()->messages())->toHaveCount(0)
|
|
->and(ActivityEvent::query()->where('type', ActivityType::MailSuppressed->value)->where('description', 'like', '%nicht versendet%')->exists())->toBeTrue();
|
|
});
|
|
|
|
test('the gate keeps the other recipients and sets the return path', function () {
|
|
/** @var TestCase $this */
|
|
config()->set('bounces.return_path', 'bounce@pressekonto.test');
|
|
suppressionService()->suppress('tot@example.test', EmailSuppressionReason::Manual, 'admin');
|
|
$user = User::factory()->create(['email' => 'lebt@example.test']);
|
|
|
|
Mail::to(['tot@example.test', 'lebt@example.test'])->send(new MagicLoginLink($user, 'https://x', '1'));
|
|
|
|
$sent = app('mailer')->getSymfonyTransport()->messages();
|
|
$email = $sent[0]->getOriginalMessage();
|
|
|
|
expect($sent)->toHaveCount(1)
|
|
->and(array_map(fn ($a) => $a->getAddress(), $email->getTo()))->toBe(['lebt@example.test'])
|
|
->and($email->getReturnPath()?->getAddress())->toBe('bounce@pressekonto.test')
|
|
->and($email->getHeaders()->get('X-Pk-Ref')?->getBodyAsString())->toStartWith('user:'.$user->id.'; s=');
|
|
|
|
// Signiert je verbliebenem Empfänger (Umsetzungsaudit 29.09.2026, R-04).
|
|
$reference = $email->getHeaders()->get('X-Pk-Ref')?->getBodyAsString();
|
|
expect(app(BounceReference::class)->verify($reference, 'lebt@example.test'))->toBeTrue()
|
|
->and(app(BounceReference::class)->verify($reference, 'tot@example.test'))->toBeFalse();
|
|
});
|
|
|
|
test('the gate signs a reference on mails without their own', function () {
|
|
/** @var TestCase $this */
|
|
Mail::raw('Hallo', fn ($message) => $message->to('kunde@example.test')->subject('Test'));
|
|
|
|
$reference = app('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage()->getHeaders()->get('X-Pk-Ref')?->getBodyAsString();
|
|
|
|
expect($reference)->toStartWith('mail; s=')
|
|
->and(app(BounceReference::class)->verify($reference, 'kunde@example.test'))->toBeTrue();
|
|
});
|
|
|
|
// ------------------------------------------------- Pfad 2: SMTP-Ablehnung
|
|
|
|
test('the smtp classifier tells mailbox failures from relay rejections', function () {
|
|
/** @var TestCase $this */
|
|
$classifier = app(SmtpFailureClassifier::class);
|
|
|
|
$hard = $classifier->classify('Expected response code "250" but got code "550", with message "550 5.1.1 <nix@example.test>: Recipient address rejected: User unknown".');
|
|
$soft = $classifier->classify('Expected response code "250" but got code "452", with message "452 4.2.2 <voll@example.test>: Mailbox full".');
|
|
$relay = $classifier->classify('Expected response code "250" but got code "554", with message "554 5.7.1 Message rejected: spam pattern".');
|
|
$down = $classifier->classify('Connection could not be established with host "mx.example.test:587"');
|
|
$fallback = $classifier->classify('550 5.1.1 User unknown', ['einzig@example.test']);
|
|
|
|
expect($hard['kind'])->toBe(EmailBounceKind::Hard)->and($hard['email'])->toBe('nix@example.test')
|
|
->and($soft['kind'])->toBe(EmailBounceKind::Soft)
|
|
->and($relay['kind'])->toBe(EmailBounceKind::Relay)
|
|
->and($down['kind'])->toBe(EmailBounceKind::Relay)
|
|
->and($fallback['kind'])->toBe(EmailBounceKind::Hard)->and($fallback['email'])->toBe('einzig@example.test');
|
|
});
|
|
|
|
test('a failed mail job with a 5.1.1 rejection suppresses the recipient', function () {
|
|
/** @var TestCase $this */
|
|
smtpJobFailed('Expected response code "250" but got code "550", with message "550 5.1.1 <nix@example.test>: User unknown".');
|
|
|
|
expect(suppressionService()->isSuppressed('nix@example.test'))->toBeTrue()
|
|
->and(EmailBounce::query()->where('source', 'smtp')->value('status'))->toBe('5.1.1');
|
|
});
|
|
|
|
test('relay rejections open the circuit breaker instead of suppressing anyone', function () {
|
|
/** @var TestCase $this */
|
|
config()->set('bounces.circuit_breaker.threshold', 3);
|
|
config()->set('monitoring.alerts.enabled', true);
|
|
config()->set('monitoring.alerts.mail_to', 'ops@example.test');
|
|
Mail::fake();
|
|
|
|
foreach (range(1, 3) as $i) {
|
|
smtpJobFailed('Expected response code "250" but got code "554", with message "554 5.7.1 spam pattern".', "kunde{$i}@example.test");
|
|
}
|
|
|
|
expect(app(MailCircuitBreaker::class)->isOpen())->toBeTrue()
|
|
->and(EmailSuppression::query()->active()->count())->toBe(0);
|
|
|
|
// Genau EIN Alert zum Öffnen — nicht einer je Ablehnung. (Die übrigen
|
|
// Alerts sind die bestehenden „Queue-Job fehlgeschlagen"-Meldungen.)
|
|
expect(Mail::sent(OperationalAlertMail::class, fn (OperationalAlertMail $mail) => str_contains($mail->summary, 'angehalten')))->toHaveCount(1);
|
|
});
|
|
|
|
test('while the breaker is open, console sends fail retryably and web sends are dropped', function () {
|
|
/** @var TestCase $this */
|
|
Cache::put('mail.circuit.open_until', now()->addMinutes(30)->toIso8601String(), 1800);
|
|
$user = User::factory()->create();
|
|
|
|
// Konsole (Worker/Scheduler): Ausnahme, damit der Job nachholbar bleibt.
|
|
expect(fn () => Mail::to('kunde@example.test')->send(new MagicLoginLink($user, 'https://x', '1')))
|
|
->toThrow(MailDeliveryPausedException::class);
|
|
|
|
// Betriebs-Alerts gehen trotzdem raus.
|
|
Mail::to('ops@example.test')->send(new OperationalAlertMail('Test', []));
|
|
expect(app('mailer')->getSymfonyTransport()->messages())->toHaveCount(1);
|
|
|
|
$this->artisan('mail:circuit')->expectsOutputToContain('ANGEHALTEN')->assertSuccessful();
|
|
$this->artisan('mail:circuit', ['--reset' => true])->assertSuccessful();
|
|
expect(app(MailCircuitBreaker::class)->isOpen())->toBeFalse();
|
|
});
|
|
|
|
// ------------------------------------------------- Import und API-Fallback
|
|
|
|
test('the bounce import feeds the central list', function () {
|
|
/** @var TestCase $this */
|
|
$path = tempnam(sys_get_temp_dir(), 'bounces');
|
|
file_put_contents($path, "presse@tot.example\n");
|
|
|
|
$this->artisan('newsletter:import-bounces', ['file' => $path])->assertSuccessful();
|
|
|
|
expect(suppressionService()->isSuppressed('presse@tot.example'))->toBeTrue()
|
|
->and(EmailSuppression::query()->first()->source)->toBe('import');
|
|
});
|
|
|
|
test('an api submission falls back to the company email when the press contact is suppressed', function () {
|
|
/** @var TestCase $this */
|
|
Queue::fake();
|
|
Mail::fake();
|
|
suppressionService()->suppress('kontakt@tot.example', EmailSuppressionReason::HardBounce, 'imap');
|
|
|
|
$api = User::factory()->create(['email' => 'api@connektar.test']);
|
|
$company = Company::factory()->businessportal24()->create(['email' => 'firma@lebt.example']);
|
|
$contact = Contact::factory()->for($company)->create(['email' => 'kontakt@tot.example', 'portal' => 'businessportal24']);
|
|
$release = PressRelease::factory()->inReview()->forPortal(Portal::Businessportal24)->create(['user_id' => $api->id, 'company_id' => $company->id]);
|
|
$release->contacts()->attach($contact->id);
|
|
PressReleaseStatusLog::query()->create(['press_release_id' => $release->id, 'from_status' => 'draft', 'to_status' => 'review', 'source' => 'api']);
|
|
|
|
app(PressReleaseService::class)->publish($release);
|
|
|
|
Mail::assertQueued(PressReleasePublished::class, fn (PressReleasePublished $mail) => $mail->hasTo('firma@lebt.example'));
|
|
});
|