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', '', ['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', '', ['status' => '4.2.2']); $service->recordBounce('voll@example.test', EmailBounceKind::Soft, 'imap', '', ['status' => '4.2.2']); expect($service->isSuppressed('voll@example.test'))->toBeFalse(); $service->recordBounce('voll@example.test', EmailBounceKind::Soft, 'imap', '', ['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', ''); $again = $service->recordBounce('voll@example.test', EmailBounceKind::Soft, 'imap', ''); 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', ''); $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', ''); 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 : Recipient address rejected: User unknown".'); $soft = $classifier->classify('Expected response code "250" but got code "452", with message "452 4.2.2 : 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 : 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')); });