diff --git a/.env.example b/.env.example index 76bba651..bd224309 100644 --- a/.env.example +++ b/.env.example @@ -126,6 +126,10 @@ MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +# Bounce-Behandlung (Roadmap 1b.6): Envelope-Sender für Rückläufer — MUSS auf +# der From-Domain liegen (SPF-Ausrichtung), z. B. bounce@pressekonto.com. +# Leer = From-Adresse. Schwellen siehe config/bounces.php. +MAIL_RETURN_PATH= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/app/Console/Commands/ImportNewsletterBounces.php b/app/Console/Commands/ImportNewsletterBounces.php index 164febe4..b58946e1 100644 --- a/app/Console/Commands/ImportNewsletterBounces.php +++ b/app/Console/Commands/ImportNewsletterBounces.php @@ -4,9 +4,11 @@ namespace App\Console\Commands; use App\Enums\ActivityType; use App\Enums\EmailNotificationType; +use App\Enums\EmailSuppressionReason; use App\Models\NewsletterSubscription; use App\Models\User; use App\Services\ActivityLog; +use App\Services\Mail\EmailSuppressionService; use App\Services\Newsletter\NewsletterSyncService; use Illuminate\Console\Command; use Illuminate\Support\Str; @@ -42,6 +44,7 @@ class ImportNewsletterBounces extends Command public function __construct( private readonly ActivityLog $activityLog, private readonly NewsletterSyncService $newsletterSync, + private readonly EmailSuppressionService $suppressions, ) { parent::__construct(); } @@ -73,6 +76,13 @@ class ImportNewsletterBounces extends Command $unknown = []; foreach ($emails as $email) { + // Seit 1b.6 zuerst die zentrale Sperrliste: Sie wirkt auf JEDE + // Mailstrecke, nicht nur auf Newsletter — und auch auf Adressen, + // die weder Konto noch Anmeldung haben (Pressekontakte, Firmen). + if (! $dryRun) { + $this->suppressions->suppress($email, EmailSuppressionReason::HardBounce, 'import', 'Bounce-Liste '.basename($path)); + } + $touchedUsers = $this->suppressUsers($email, $dryRun, $optedOut, $alreadyOptedOut); $touchedSubscriptions = $this->suppressSubscriptions($email, $dryRun, $unsubscribed, $alreadyUnsubscribed); @@ -85,6 +95,7 @@ class ImportNewsletterBounces extends Command } $prefix = $dryRun ? '[Dry-Run] ' : ''; + $this->info(sprintf('%s%d Adressen in die Sperrliste (email_suppressions) übernommen.', $prefix, count($emails))); $this->info(sprintf( '%s%d Adressen gelesen: %d abgemeldet, %d waren bereits abgemeldet, %d ohne User-Konto.', $prefix, diff --git a/app/Console/Commands/MailCircuitCommand.php b/app/Console/Commands/MailCircuitCommand.php new file mode 100644 index 00000000..bf38e2a4 --- /dev/null +++ b/app/Console/Commands/MailCircuitCommand.php @@ -0,0 +1,36 @@ +option('reset')) { + $breaker->reset(); + $this->info('Circuit Breaker zurückgesetzt — der Versand läuft wieder. Liegengebliebene Mails: php artisan queue:retry all'); + + return self::SUCCESS; + } + + $until = $breaker->openUntil(); + + if ($until !== null) { + $this->warn(sprintf('Versand ANGEHALTEN bis %s.', $until->format('d.m.Y H:i'))); + } else { + $this->info(sprintf('Versand läuft. Relay-Ablehnungen im aktuellen Fenster: %d.', $breaker->pendingRejections())); + } + + return self::SUCCESS; + } +} diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php index 8f03d65e..cbecd94c 100644 --- a/app/Enums/ActivityType.php +++ b/app/Enums/ActivityType.php @@ -28,6 +28,7 @@ enum ActivityType: string case Anonymization = 'anonymization'; case Correction = 'correction'; case ContentRemoval = 'content_removal'; + case MailSuppressed = 'mail_suppressed'; public function label(): string { @@ -51,6 +52,7 @@ enum ActivityType: string self::Anonymization => 'Anonymisierung', self::Correction => 'Korrektur', self::ContentRemoval => 'Entfernung (410)', + self::MailSuppressed => 'Mail-Sperre', }; } @@ -74,6 +76,7 @@ enum ActivityType: string self::Anonymization => 'red', self::Correction => 'blue', self::ContentRemoval => 'red', + self::MailSuppressed => 'orange', }; } } diff --git a/app/Enums/EmailBounceKind.php b/app/Enums/EmailBounceKind.php new file mode 100644 index 00000000..481ff48b --- /dev/null +++ b/app/Enums/EmailBounceKind.php @@ -0,0 +1,28 @@ + 'hart', + self::Soft => 'weich', + self::Relay => 'Relay', + }; + } +} diff --git a/app/Enums/EmailSuppressionReason.php b/app/Enums/EmailSuppressionReason.php new file mode 100644 index 00000000..c04c49a3 --- /dev/null +++ b/app/Enums/EmailSuppressionReason.php @@ -0,0 +1,21 @@ + 'Postfach existiert nicht', + self::SoftBounces => 'wiederholt unzustellbar', + self::Manual => 'manuell gesperrt', + }; + } +} diff --git a/app/Exceptions/MailDeliveryPausedException.php b/app/Exceptions/MailDeliveryPausedException.php new file mode 100644 index 00000000..2902c714 --- /dev/null +++ b/app/Exceptions/MailDeliveryPausedException.php @@ -0,0 +1,12 @@ +message; + $mailable = $event->data['__laravel_mailable'] ?? null; + $isAlert = $mailable === OperationalAlertMail::class; + + if ($returnPath = config('bounces.return_path')) { + $message->returnPath((string) $returnPath); + } + + if (! $isAlert && $this->breaker->isOpen()) { + if (app()->runningInConsole()) { + throw new MailDeliveryPausedException(sprintf( + 'Mailversand angehalten bis %s (Circuit Breaker).', + $this->breaker->openUntil()?->format('H:i') ?? '?', + )); + } + + $this->activityLog->record( + ActivityType::MailSuppressed, + sprintf('E-Mail „%s“ verworfen: Versand angehalten (Circuit Breaker).', (string) $message->getSubject()), + meta: ['to' => $this->addresses($message->getTo()), 'mailable' => $mailable], + ); + + return false; + } + + $to = $this->addresses($message->getTo()); + $cc = $this->addresses($message->getCc()); + $bcc = $this->addresses($message->getBcc()); + $suppressed = $this->suppressions->suppressedAmong([...$to, ...$cc, ...$bcc]); + + if ($suppressed === []) { + return true; + } + + $keep = fn (Address $address): bool => ! in_array(EmailSuppressionService::normalise($address->getAddress()), $suppressed, true); + + $message->to(...array_values(array_filter($message->getTo(), $keep))); + $message->cc(...array_values(array_filter($message->getCc(), $keep))); + $message->bcc(...array_values(array_filter($message->getBcc(), $keep))); + + if ($message->getTo() !== []) { + return true; + } + + $this->activityLog->record( + ActivityType::MailSuppressed, + sprintf('E-Mail „%s“ an %s nicht versendet: Adresse gesperrt.', (string) $message->getSubject(), implode(', ', $suppressed)), + actorLabel: $suppressed[0], + meta: array_filter(['to' => $to, 'suppressed' => $suppressed, 'mailable' => $mailable]), + ); + + return false; + } + + /** + * @param Address[] $addresses + * @return list + */ + private function addresses(array $addresses): array + { + return array_values(array_map(fn (Address $address): string => $address->getAddress(), $addresses)); + } +} diff --git a/app/Listeners/RecordSmtpFailure.php b/app/Listeners/RecordSmtpFailure.php new file mode 100644 index 00000000..a2ec838f --- /dev/null +++ b/app/Listeners/RecordSmtpFailure.php @@ -0,0 +1,105 @@ +exception; + + if ($exception instanceof MailDeliveryPausedException || ! $this->isMailFailure($exception)) { + return; + } + + $recipients = $this->recipientsOf($event); + $result = $this->classifier->classify($exception->getMessage(), $recipients); + + if ($result['kind'] === EmailBounceKind::Relay) { + $this->breaker->recordRelayRejection($exception->getMessage()); + + if ($result['email'] !== null) { + $this->suppressions->recordBounce($result['email'], EmailBounceKind::Relay, 'smtp', 'smtp:'.$event->job->uuid(), [ + 'status' => $result['status'], + 'diagnostic' => $exception->getMessage(), + ]); + } + + return; + } + + $this->suppressions->recordBounce((string) $result['email'], $result['kind'], 'smtp', 'smtp:'.$event->job->uuid(), [ + 'action' => 'failed', + 'status' => $result['status'], + 'diagnostic' => $exception->getMessage(), + ]); + } + + private function isMailFailure(Throwable $exception): bool + { + for ($e = $exception; $e !== null; $e = $e->getPrevious()) { + if ($e instanceof TransportExceptionInterface) { + return true; + } + } + + return false; + } + + /** + * Empfänger aus dem serialisierten Job — derselbe Payload, den der Worker + * ohnehin entpackt hat. Nur der Rückfall, wenn die Fehlermeldung die + * Adresse nicht nennt. + * + * @return list + */ + private function recipientsOf(JobFailed $event): array + { + try { + $command = $event->job->payload()['data']['command'] ?? null; + + if (! is_string($command)) { + return []; + } + + $job = unserialize($command); + + if (! $job instanceof SendQueuedMailable) { + return []; + } + + $to = $job->mailable->to ?? []; + + return array_values(array_filter(array_map( + fn (mixed $entry): ?string => is_array($entry) ? ($entry['address'] ?? null) : null, + is_array($to) ? $to : [], + ))); + } catch (Throwable) { + return []; + } + } +} diff --git a/app/Mail/Concerns/Unsubscribable.php b/app/Mail/Concerns/Unsubscribable.php index 75cf1483..7c24896d 100644 --- a/app/Mail/Concerns/Unsubscribable.php +++ b/app/Mail/Concerns/Unsubscribable.php @@ -34,9 +34,20 @@ trait Unsubscribable public function headers(): Headers { - return new Headers(text: [ + return new Headers(text: array_filter([ 'List-Unsubscribe' => '<'.$this->unsubscribeUrl.'>', 'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click', - ]); + // Bounce-Referenz (Roadmap 1b.6), sofern die Mailable eine hat. + (string) config('bounces.reference_header', 'X-Pk-Ref') => $this->bounceReference(), + ])); + } + + /** + * Ein DSN bringt die Original-Header zurück; hiermit ordnet der + * Bounce-Parser den Rückläufer dem Datensatz zu. Mailables überschreiben. + */ + public function bounceReference(): ?string + { + return null; } } diff --git a/app/Mail/MagicLoginLink.php b/app/Mail/MagicLoginLink.php index 1404f1d5..44b3cdeb 100644 --- a/app/Mail/MagicLoginLink.php +++ b/app/Mail/MagicLoginLink.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; +use Illuminate\Mail\Mailables\Headers; use Illuminate\Queue\SerializesModels; class MagicLoginLink extends Mailable implements ShouldQueue @@ -20,6 +21,15 @@ class MagicLoginLink extends Mailable implements ShouldQueue public string $expiresAt ) {} + /** + * Eigener Header je Mail: Ein DSN bringt die Original-Header zurück, so + * ordnet der Bounce-Parser den Rückläufer dem Datensatz zu (Roadmap 1b.6). + */ + public function headers(): Headers + { + return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]); + } + public function envelope(): Envelope { return new Envelope( diff --git a/app/Mail/PressReleasePublished.php b/app/Mail/PressReleasePublished.php index fbf5fef8..bac1e872 100644 --- a/app/Mail/PressReleasePublished.php +++ b/app/Mail/PressReleasePublished.php @@ -84,6 +84,16 @@ class PressReleasePublished extends Mailable return self::tagged(sprintf('%s/%s/newsroom/%s/verwalten', $host, $edition, $company->slug), $pressRelease->id); } + /** + * Bounce-Referenz: Ein DSN bringt die Original-Header zurück, so ordnet + * der Parser den Rückläufer der Meldung zu (Roadmap 1b.6). Gesetzt über + * den gemeinsamen headers() des Unsubscribable-Traits. + */ + public function bounceReference(): string + { + return sprintf('pm:%d:company:%s', $this->pressRelease->id, $this->pressRelease->company_id ?? '-'); + } + public function envelope(): Envelope { return new Envelope( diff --git a/app/Mail/PressReleaseRejected.php b/app/Mail/PressReleaseRejected.php index 86ca074e..061ef1ec 100644 --- a/app/Mail/PressReleaseRejected.php +++ b/app/Mail/PressReleaseRejected.php @@ -46,6 +46,16 @@ class PressReleaseRejected extends Mailable $this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected); } + /** + * Bounce-Referenz: Ein DSN bringt die Original-Header zurück, so ordnet + * der Parser den Rückläufer der Meldung zu (Roadmap 1b.6). Gesetzt über + * den gemeinsamen headers() des Unsubscribable-Traits. + */ + public function bounceReference(): string + { + return sprintf('pm:%d:company:%s', $this->pressRelease->id, $this->pressRelease->company_id ?? '-'); + } + public function envelope(): Envelope { return new Envelope( diff --git a/app/Models/EmailBounce.php b/app/Models/EmailBounce.php new file mode 100644 index 00000000..cf1ea28c --- /dev/null +++ b/app/Models/EmailBounce.php @@ -0,0 +1,38 @@ + + */ + protected function casts(): array + { + return [ + 'kind' => EmailBounceKind::class, + 'received_at' => 'datetime', + ]; + } +} diff --git a/app/Models/EmailSuppression.php b/app/Models/EmailSuppression.php new file mode 100644 index 00000000..ad861e58 --- /dev/null +++ b/app/Models/EmailSuppression.php @@ -0,0 +1,71 @@ + + */ + protected function casts(): array + { + return [ + 'reason' => EmailSuppressionReason::class, + 'first_bounced_at' => 'datetime', + 'last_bounced_at' => 'datetime', + 'suppressed_at' => 'datetime', + 'released_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function releasedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'released_by_user_id'); + } + + /** + * Gesperrt heißt: gesetzt und nicht freigegeben. Eine Freigabe hebt die + * Sperre auf; ein späterer Bounce setzt `suppressed_at` neu und löscht + * die Freigabe wieder — der Verlauf steht im Aktivitätslog. + */ + public function isActive(): bool + { + return $this->suppressed_at !== null && $this->released_at === null; + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->whereNotNull('suppressed_at')->whereNull('released_at'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 44f24edf..c26f7273 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Contracts\NewsletterSyncClient; +use App\Exceptions\MailDeliveryPausedException; use App\Helpers\ThemeHelper; use App\Http\Middleware\BlockAdminDuringImpersonation; use App\Http\Middleware\EnsureUserIsAdmin; @@ -10,8 +11,10 @@ use App\Http\Middleware\EnsureUserIsCustomer; use App\Http\Middleware\LogSlowAdminRequests; use App\Http\Middleware\SetEdition; use App\Listeners\ActivateUserAfterVerification; +use App\Listeners\GateOutgoingMail; use App\Listeners\RecordAuthActivity; use App\Listeners\RecordMailActivity; +use App\Listeners\RecordSmtpFailure; use App\Listeners\SyncCompanyMembershipsOnLogin; use App\Models\AdminPreset; use App\Models\Category; @@ -41,6 +44,7 @@ use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Events\QueryExecuted; +use Illuminate\Mail\Events\MessageSending; use Illuminate\Mail\Events\MessageSent; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Queue\Events\JobFailed; @@ -123,6 +127,17 @@ class AppServiceProvider extends ServiceProvider // laufen auf der `classification`-Queue — ein stiller Stau in // `failed_jobs` würde jede Einreichung in der Prüfung hängen lassen. Queue::failing(function (JobFailed $event): void { + // Bounce-Pfad 2: SMTP-Ablehnung im Worker → Sperre bzw. Circuit + // Breaker (Roadmap 1b.6). Läuft VOR dem Alert, damit der Breaker + // seinen eigenen, einmaligen Alert setzen kann. + app(RecordSmtpFailure::class)->record($event); + + // Der Breaker hat den Versand angehalten: Das ist EIN Alert (beim + // Öffnen), nicht einer je liegengebliebener Mail. + if ($event->exception instanceof MailDeliveryPausedException) { + return; + } + OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [ 'connection' => $event->connectionName, 'queue' => $event->job->getQueue(), @@ -139,6 +154,10 @@ class AppServiceProvider extends ServiceProvider Event::listen(Registered::class, [RecordAuthActivity::class, 'recordRegistered']); Event::listen(Login::class, [RecordAuthActivity::class, 'recordLogin']); + // Das eine Gate vor jeder ausgehenden Mail: Suppression, Return-Path, + // Circuit Breaker (Roadmap 1b.6). Gibt false zurück → Mail geht nicht raus. + Event::listen(MessageSending::class, [GateOutgoingMail::class, 'gate']); + // Jede tatsächlich versendete E-Mail landet ebenfalls im Verlauf. Event::listen(MessageSent::class, [RecordMailActivity::class, 'recordSent']); diff --git a/app/Services/Mail/EmailSuppressionService.php b/app/Services/Mail/EmailSuppressionService.php new file mode 100644 index 00000000..794b031a --- /dev/null +++ b/app/Services/Mail/EmailSuppressionService.php @@ -0,0 +1,212 @@ +active()->where('email', $key)->exists(); + } + + /** + * Welche dieser Adressen sind gesperrt? Eine Abfrage für alle Empfänger + * einer Mail. + * + * @param list $emails + * @return list normalisiert + */ + public function suppressedAmong(array $emails): array + { + $keys = array_values(array_filter(array_map(self::normalise(...), $emails))); + + if ($keys === []) { + return []; + } + + return EmailSuppression::query()->active()->whereIn('email', $keys)->pluck('email')->all(); + } + + /** + * Ein Rückläufer-Ereignis. Idempotent über die Message-ID: Kommt dieselbe + * Mail ein zweites Mal (abgebrochener Abhol-Lauf), passiert nichts. + * + * @param array{action?: string|null, status?: string|null, diagnostic?: string|null, reference?: string|null, received_at?: Carbon|null} $details + * @return EmailBounce|null null, wenn das Ereignis schon bekannt war + */ + public function recordBounce(string $email, EmailBounceKind $kind, string $source, string $messageId, array $details = []): ?EmailBounce + { + $key = self::normalise($email); + + if ($key === null) { + return null; + } + + try { + $bounce = EmailBounce::query()->create([ + 'message_id' => mb_substr($messageId, 0, 255), + 'email' => $key, + 'kind' => $kind, + 'source' => $source, + 'action' => isset($details['action']) ? mb_substr($details['action'], 0, 32) : null, + 'status' => isset($details['status']) ? mb_substr($details['status'], 0, 16) : null, + 'diagnostic' => isset($details['diagnostic']) ? mb_substr($details['diagnostic'], 0, 500) : null, + 'reference' => isset($details['reference']) ? mb_substr($details['reference'], 0, 120) : null, + 'received_at' => $details['received_at'] ?? now(), + ]); + } catch (UniqueConstraintViolationException) { + return null; + } + + if ($kind === EmailBounceKind::Relay) { + // Kein Empfängerproblem — nur protokolliert, der Circuit Breaker + // zählt es separat. + return $bounce; + } + + $this->applyBounce($key, $kind, $source, $bounce); + + return $bounce; + } + + /** + * Sperre von Hand oder aus einer Bounce-Liste (Import). + */ + public function suppress(string $email, EmailSuppressionReason $reason, string $source, ?string $note = null): ?EmailSuppression + { + $key = self::normalise($email); + + if ($key === null) { + return null; + } + + $suppression = EmailSuppression::query()->firstOrNew(['email' => $key]); + $wasActive = $suppression->exists && $suppression->isActive(); + + $suppression->fill([ + 'reason' => $reason, + 'source' => $source, + 'suppressed_at' => now(), + 'released_at' => null, + 'released_by_user_id' => null, + 'note' => $note !== null ? mb_substr($note, 0, 500) : $suppression->note, + ])->save(); + + if (! $wasActive) { + $this->activityLog->record( + ActivityType::MailSuppressed, + sprintf('%s gesperrt: %s (%s)', $key, $reason->label(), $source), + subject: $suppression, + actorLabel: $source, + ); + } + + return $suppression; + } + + /** + * Freigabe im Admin, wenn jemand die Adresse repariert hat. Ein späterer + * Bounce setzt die Sperre neu. + */ + public function release(EmailSuppression $suppression, ?User $by = null, ?string $note = null): void + { + $suppression->forceFill([ + 'released_at' => now(), + 'released_by_user_id' => $by?->getKey(), + 'note' => $note !== null ? mb_substr($note, 0, 500) : $suppression->note, + ])->save(); + + $this->activityLog->record( + ActivityType::MailSuppressed, + sprintf('%s freigegeben', $suppression->email), + user: $by, + subject: $suppression, + ); + } + + private function applyBounce(string $email, EmailBounceKind $kind, string $source, EmailBounce $bounce): void + { + $suppression = EmailSuppression::query()->firstOrNew(['email' => $email], [ + 'reason' => EmailSuppressionReason::HardBounce, + 'source' => $source, + ]); + $wasActive = $suppression->exists && $suppression->isActive(); + + $suppression->first_bounced_at ??= $bounce->received_at; + $suppression->last_bounced_at = $bounce->received_at; + + if ($kind === EmailBounceKind::Hard) { + $suppression->hard_bounce_count++; + $suppression->reason = EmailSuppressionReason::HardBounce; + $suppression->source = $source; + $suppression->suppressed_at = now(); + $suppression->released_at = null; + $suppression->released_by_user_id = null; + } else { + $suppression->soft_bounce_count++; + + $recentSoft = EmailBounce::query() + ->where('email', $email) + ->where('kind', EmailBounceKind::Soft->value) + ->where('received_at', '>=', now()->subDays((int) config('bounces.soft.window_days', 7))) + ->count(); + + if (! $wasActive && $recentSoft >= (int) config('bounces.soft.threshold', 3)) { + $suppression->reason = EmailSuppressionReason::SoftBounces; + $suppression->source = $source; + $suppression->suppressed_at = now(); + $suppression->released_at = null; + $suppression->released_by_user_id = null; + } + } + + $suppression->save(); + + if (! $wasActive && $suppression->isActive()) { + $this->activityLog->record( + ActivityType::MailSuppressed, + sprintf('%s gesperrt: %s (%s%s)', $email, $suppression->reason->label(), $source, $bounce->status ? ', '.$bounce->status : ''), + subject: $suppression, + actorLabel: $source, + meta: array_filter(['reference' => $bounce->reference, 'diagnostic' => $bounce->diagnostic]), + ); + } + } +} diff --git a/app/Services/Mail/MailCircuitBreaker.php b/app/Services/Mail/MailCircuitBreaker.php new file mode 100644 index 00000000..9cdd8f8b --- /dev/null +++ b/app/Services/Mail/MailCircuitBreaker.php @@ -0,0 +1,83 @@ +addMinutes($windowMinutes)); + $count = (int) Cache::increment(self::CounterKey); + + if ($count < $threshold || $this->isOpen()) { + return; + } + + $pauseMinutes = max(1, (int) config('bounces.circuit_breaker.pause_minutes', 30)); + $until = now()->addMinutes($pauseMinutes); + + Cache::put(self::OpenKey, $until->toIso8601String(), $until); + Cache::forget(self::CounterKey); + + OperationalAlert::send( + sprintf('Mailversand angehalten bis %s: %d Relay-Ablehnungen in %d Minuten', $until->format('H:i'), $count, $windowMinutes), + [ + 'last_rejection' => $detail, + 'hint' => 'Absenderruf/Relay prüfen. Fehlgeschlagene Queue-Mails danach mit `php artisan queue:retry all` nachholen; `php artisan mail:circuit --reset` hebt die Pause vorzeitig auf.', + ], + ); + } + + public function isOpen(): bool + { + return $this->openUntil() !== null; + } + + public function openUntil(): ?Carbon + { + $until = Cache::get(self::OpenKey); + + if (! is_string($until)) { + return null; + } + + $carbon = Carbon::parse($until); + + return $carbon->isFuture() ? $carbon : null; + } + + public function reset(): void + { + Cache::forget(self::OpenKey); + Cache::forget(self::CounterKey); + } + + public function pendingRejections(): int + { + return (int) Cache::get(self::CounterKey, 0); + } +} diff --git a/app/Services/Mail/SmtpFailureClassifier.php b/app/Services/Mail/SmtpFailureClassifier.php new file mode 100644 index 00000000..02ca9f68 --- /dev/null +++ b/app/Services/Mail/SmtpFailureClassifier.php @@ -0,0 +1,59 @@ + $recipients Empfänger des Jobs — Rückfall, wenn die Meldung keine Adresse nennt und es genau einen gibt + * @return array{kind: EmailBounceKind, email: string|null, status: string|null, code: int|null} + */ + public function classify(string $message, array $recipients = []): array + { + $code = preg_match('/\b([45]\d{2})\b/', $message, $m) ? (int) $m[1] : null; + $status = preg_match('/\b([45]\.\d{1,3}\.\d{1,3})\b/', $message, $m) ? $m[1] : null; + $email = $this->recipientIn($message) ?? (count($recipients) === 1 ? EmailSuppressionService::normalise($recipients[0]) : null); + + $isMailboxFailure = $status !== null && str_starts_with($status, '5.1.') + || ($status === null && $code === 550 && preg_match('/user unknown|no such user|unknown user|does not exist|mailbox unavailable|recipient address rejected/i', $message) === 1); + + if ($isMailboxFailure && $email !== null) { + return ['kind' => EmailBounceKind::Hard, 'email' => $email, 'status' => $status, 'code' => $code]; + } + + if (($status !== null && str_starts_with($status, '4.') || ($status === null && $code !== null && $code < 500)) && $email !== null) { + return ['kind' => EmailBounceKind::Soft, 'email' => $email, 'status' => $status, 'code' => $code]; + } + + return ['kind' => EmailBounceKind::Relay, 'email' => $email, 'status' => $status, 'code' => $code]; + } + + private function recipientIn(string $message): ?string + { + if (preg_match('/<([^<>\s]+@[^<>\s]+)>/', $message, $m) === 1) { + return EmailSuppressionService::normalise($m[1]); + } + + if (preg_match('/([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})/i', $message, $m) === 1) { + return EmailSuppressionService::normalise($m[1]); + } + + return null; + } +} diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index c290f3c6..05234e07 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -21,6 +21,7 @@ use App\Models\User; use App\Scopes\PortalScope; use App\Services\ActivityLog; use App\Services\Admin\AdminPerformanceCache; +use App\Services\Mail\EmailSuppressionService; use App\Support\WebCaches; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; @@ -40,6 +41,7 @@ class PressReleaseService private readonly BlacklistService $blacklist, private readonly AdminPerformanceCache $adminCache, private readonly ActivityLog $activityLog, + private readonly EmailSuppressionService $suppressions, ) {} /** @@ -702,12 +704,17 @@ class PressReleaseService $company = Company::withoutGlobalScopes()->find($pressRelease->company_id); $companyName = trim((string) $company?->name); - if ($contact) { + // Gesperrte Adressen (Bounce-Suppression, 1b.6) überspringen: + // Kontakt → Firmen-E-Mail → sonst gar nicht. Nie zurück an den + // API-Einlieferer — der soll nicht zugespammt werden. + if ($contact && ! $this->suppressions->isSuppressed($contact->email)) { $recipient = $contact->email; $recipientName = trim($contact->first_name.' '.$contact->last_name) ?: $companyName; - } elseif ($company && filled($company->email)) { + } elseif ($company && filled($company->email) && ! $this->suppressions->isSuppressed($company->email)) { $recipient = $company->email; $recipientName = $companyName; + } else { + return; } } diff --git a/config/bounces.php b/config/bounces.php new file mode 100644 index 00000000..f9d596dd --- /dev/null +++ b/config/bounces.php @@ -0,0 +1,44 @@ + env('MAIL_RETURN_PATH'), + + 'reference_header' => 'X-Pk-Ref', + + 'soft' => [ + 'threshold' => (int) env('BOUNCE_SOFT_THRESHOLD', 3), + 'window_days' => (int) env('BOUNCE_SOFT_WINDOW_DAYS', 7), + ], + + 'circuit_breaker' => [ + 'threshold' => (int) env('MAIL_CIRCUIT_THRESHOLD', 5), + 'window_minutes' => (int) env('MAIL_CIRCUIT_WINDOW_MINUTES', 15), + 'pause_minutes' => (int) env('MAIL_CIRCUIT_PAUSE_MINUTES', 30), + ], + +]; diff --git a/database/migrations/2026_09_15_120000_create_email_suppressions_tables.php b/database/migrations/2026_09_15_120000_create_email_suppressions_tables.php new file mode 100644 index 00000000..edfa3151 --- /dev/null +++ b/database/migrations/2026_09_15_120000_create_email_suppressions_tables.php @@ -0,0 +1,67 @@ +id(); + $table->string('email', 190)->unique(); + $table->string('reason', 32); + $table->string('source', 32); + $table->unsignedInteger('hard_bounce_count')->default(0); + $table->unsignedInteger('soft_bounce_count')->default(0); + $table->timestamp('first_bounced_at')->nullable(); + $table->timestamp('last_bounced_at')->nullable(); + $table->timestamp('suppressed_at')->nullable(); + $table->timestamp('released_at')->nullable(); + $table->foreignId('released_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('note', 500)->nullable(); + $table->timestamps(); + + $table->index('suppressed_at', 'email_suppressions_suppressed_idx'); + }); + + Schema::create('email_bounces', function (Blueprint $table) { + $table->id(); + $table->string('message_id', 255)->unique(); + $table->string('email', 190)->index(); + $table->string('kind', 16); + $table->string('source', 32); + $table->string('action', 32)->nullable(); + $table->string('status', 16)->nullable(); + $table->string('diagnostic', 500)->nullable(); + $table->string('reference', 120)->nullable(); + $table->timestamp('received_at'); + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_bounces'); + Schema::dropIfExists('email_suppressions'); + } +}; diff --git a/dev/2026-09-01/bounce-mails-2026-09-15.md b/dev/2026-09-01/bounce-mails-2026-09-15.md new file mode 100644 index 00000000..382d3376 --- /dev/null +++ b/dev/2026-09-01/bounce-mails-2026-09-15.md @@ -0,0 +1,64 @@ +VERP im Vollausbau (eine eigene Adresse pro Empfänger) brauchst du nicht. Ein einzelnes Bounce-Postfach plus ein eigener Header reichen. + +## Der Aufbau + +**1. Return-Path trennen** + +Envelope-Sender auf `bounce@presseecho.de`, `From` bleibt `redaktion@`. Dann landen Rückläufer in einem eigenen Postfach statt in deinem Posteingang. Das ist eine Zeile, global über einen `MessageSending`-Listener: + +```php +$event->message->returnPath('bounce@presseecho.de'); +``` + +**2. Eigenen Header mitschicken** + +```php +$event->message->getHeaders()->addTextHeader('X-Pk-Ref', "pm:{$pm->id}:contact:{$contact->id}"); +``` + +Das ist der eigentliche Trick. Ein RFC-3464-DSN besteht aus drei Teilen, und der dritte trägt die Header der Originalnachricht, damit der Absender den Bounce dem Versand zuordnen kann. Dein Header kommt also zurück. Du weißt damit exakt, welche PM an welchen Kontakt ging, ohne aus dem Bounce-Fließtext Rückschlüsse ziehen zu müssen. Der Nutzen von VERP, ohne dessen Adressverwaltung. + +**3. Abholen: `webklex/laravel-imap`** + +Das ist der De-facto-Standard und wird aktiv gepflegt (letztes Update Juli 2026). Es bringt sowohl die native php-imap-Erweiterung als auch ein eigenes IMAP-Protokoll mit, du bist also nicht darauf angewiesen, dass Ploi dir die Extension kompiliert. IDLE brauchst du nicht, ein Scheduler alle zehn Minuten auf `UNSEEN` reicht bei zwei bis drei Mails am Tag. + +**4. Parsen** + +Kein Regex auf den Body. Der mittlere Teil `message/delivery-status` enthält die Felder strukturiert, Parser müssen nicht raten. Du brauchst genau vier Felder: + +| Feld | Bedeutung | +|---|---| +| `Final-Recipient` | `rfc822; user@example.com` — die Adresse | +| `Action` | `failed` = Bounce, `delayed` = temporär, `delivered`/`relayed` = ignorieren | +| `Status` | `5.1.1` — Klasse 5 permanent, Klasse 4 temporär | +| `Diagnostic-Code` | `smtp; 550 ...` — nur für dein Log | + +Feldnamen sind case-insensitive, `Status:` und `status:` sind beide gültig. Maßgeblich ist `Action`, nicht der Statuscode allein. + +## Zu den Paketen + +Fertige Bounce-Parser für PHP gibt es, aber keiner davon ist etwas, worauf ich ein Produktivsystem stellen würde: + +| Paket | Einschätzung | +|---|---| +| `cfortune/PHP-Bounce-Handler` | Parst multipart/report nach RFC 1892 und 1894 — das sind die Vorgänger von 3461/3464. Stammt aus der phpclasses-Ära, kein Composer-Paket, keine Namespaces. Als Nachschlagewerk für Statuscodes brauchbar, als Fundament nicht. | +| `galata90/php-mail-bounce-handler` | Prüft IMAP-Postfach oder eml-Dateien und versucht bei fehlerhaften Bounces heuristisch den Status zu ermitteln. Näher dran, aber ebenfalls wenig gepflegt und setzt für den IMAP-Modus die php_imap-Extension voraus. | +| `molnix/bounced-mail-manager` | Liest IMAP, erkennt Bounces, arbeitet mit eigenen Custom-Headern in ausgehenden Mails — konzeptionell genau dein Ansatz. Aber ein Ein-Personen-Paket mit sehr geringer Verbreitung. Für einen Solo-Betreiber ist eine Abhängigkeit, die niemand außer dir benutzt, selbst ein Wartungsrisiko. | + +Mein Vorschlag: `webklex/laravel-imap` für den Transport, dazu ein eigener DSN-Parser von etwa 100 bis 150 Zeilen. Sobald das MIME sauber zerlegt ist, ist der DSN-Teil banal — Teil mit Content-Type `message/delivery-status` suchen, an Leerzeilen in Recipient-Blöcke splitten, `Key: Value` einlesen. Die genannten Pakete nimmst du als Testkorpus, nicht als Dependency; die eml-Sammlungen darin sind für deine Tests mehr wert als der Code. + +## Zweiter Pfad, der ohne IMAP läuft + +Nicht jeder Fehlschlag erzeugt eine Rückläufermail. Manche Server weisen direkt während der SMTP-Verbindung mit 5xx ab, statt einen DSN zu erzeugen — die Behandlung muss beide Wege abdecken. Bei dir wirft Symfony dann eine `TransportException` und der Queue-Job schlägt fehl. Wenn du deine Mails über die Queue verschickst (was du solltest), fängst du in `failed()` einen Teil der Fälle sofort ab, ohne dass IMAP beteiligt ist. Das ist billiger als der IMAP-Pfad und sollte zuerst gebaut werden. + +## Was du im Parser nicht vergessen darfst + +**Zustellbestätigungen aussortieren.** Ein DSN mit `Action: delivered` oder `relayed` ist kein Bounce. Wer nur nach `Final-Recipient` greift, blockt Adressen, die funktioniert haben. + +**Nicht-DSNs nicht raten lassen.** Handgeschriebene Ablehnungen und alte MTAs liefern Freitext. Was der Parser nicht sicher zerlegt, kommt in einen Ordner `Unparsed` und in eine Admin-Liste. Zwei Mails pro Woche manuell einordnen ist akzeptabel, eine falsch geblockte Adresse nicht. + +**Idempotenz über `Message-ID`.** Wenn der Job zwischen Verarbeitung und Verschieben abbricht, liest der nächste Lauf dieselbe Mail erneut. Bounce-Zähler brauchen deshalb eine Unique-Prüfung auf die Message-ID des Rückläufers, sonst zählst du einen Soft Bounce dreimal und sperrst zu früh. + +**Der Circuit Breaker aus dem letzten Durchgang bleibt Pflicht** — bei Eigenversand über Hetzner sogar dringender als bei einem Dienstleister, weil dort ein IP-Reputationsproblem sichtbar wird und du es hier selbst merken musst. + +Ein Punkt noch, den du kennen solltest, auch wenn die Entscheidung steht: Beim Selbstversand ohne Dienstleister ist der Bounce-Parser nicht dein Hauptrisiko, sondern der Anteil an Mails, die stumm im Spam-Ordner verschwinden. Dafür siehst du weder Bounce noch Exception. Wenn du dir später Sichtbarkeit darauf verschaffen willst, ist ein DMARC-Report-Postfach der billigste Einstieg — gleicher IMAP-Mechanismus, anderes Format. \ No newline at end of file diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index adbbc958..b311eac5 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -1004,3 +1004,54 @@ Land, Logo und Pressekontakte. Neu: die Mail), sonst `organic`. Tests: `tests/Feature/NewsroomProfileTest.php` (4). + +## 1b.6 — Bounce-Suppression (Schritte 1 und 2, 15.09.2026) + +Grundlage ist das Konzept `bounce-mails-2026-09-15.md` (eigenes Postfach als +Return-Path, `X-Pk-Ref`-Header, IMAP-Abholung mit eigenem DSN-Parser, +SMTP-Ablehnung als zweiter Pfad, Circuit Breaker). Zwei Korrekturen daran: +Der Return-Path muss auf der **From-Domain** liegen (`bounce@pressekonto.com`, +nicht presseecho.de — SPF-Ausrichtung für DMARC), und nicht jede 5xx ist ein +Empfängerproblem: 554-Spam-Pattern des Relays sperrt sonst gesunde Adressen. + +**Schritt 1 — Liste und Gate** + +- `email_bounces` (Ereignis-Log, idempotent über `message_id`) und + `email_suppressions` (Zustand je Adresse: Grund, Zähler, `suppressed_at`, + `released_at`). Hart → sofort gesperrt; weich → ab dem dritten in sieben + Tagen (`config/bounces.php`). Freigabe setzt `released_at`, der nächste + Bounce löscht sie wieder. +- `EmailSuppressionService` — die eine Stelle, die „nicht erreichbar" heißt. +- `GateOutgoingMail` am `MessageSending`-Event: streicht gesperrte Empfänger + aus jeder ausgehenden Mail, verwirft sie ganz, wenn keiner bleibt (mit + Eintrag im Aktivitätsverlauf, Typ „Mail-Sperre"), setzt den Return-Path + (`MAIL_RETURN_PATH`). Eine Stelle für Magic Links, Benachrichtigungen, + Newsletter und alles Künftige. +- `X-Pk-Ref` auf Veröffentlichungs-, Ablehnungs- und Magic-Link-Mail + (`pm:ID:company:ID`, `user:ID`). +- API-Meldungen: Ist der Pressekontakt gesperrt, geht die Mail an die + Firmen-E-Mail; ist die auch gesperrt, an niemanden — nie an den + API-Einlieferer. +- `newsletter:import-bounces` schreibt jetzt zuerst in die zentrale Liste — + damit erreicht die Handliste auch Pressekontakte und Firmen ohne Konto. + +**Schritt 2 — SMTP-Pfad und Circuit Breaker** + +- `RecordSmtpFailure` am `JobFailed`-Event: `SmtpFailureClassifier` deutet + die `TransportException`. `5.1.x` mit Adresse → harter Bounce, `4.x.x` → + weicher, alles andere (5.7.x, 554, Verbindung) → Relay-Ablehnung. +- `MailCircuitBreaker`: ab 5 Relay-Ablehnungen in 15 Minuten wird der + Versand 30 Minuten angehalten, **ein** Betriebs-Alert. Im Worker schlagen + Mails dann fehl (nachholbar mit `queue:retry all`), im Web-Kontext werden + sie verworfen und protokolliert; Betriebs-Alerts gehen immer durch. + `php artisan mail:circuit [--reset]` zeigt bzw. hebt die Pause auf. + +Tests: `tests/Feature/EmailSuppressionTest.php` (11). + +**Deploy:** `migrate --force`, `config:cache`, dann in der `.env` +`MAIL_RETURN_PATH=bounce@pressekonto.com` — erst, wenn das Postfach existiert. +Danach einmal `newsletter:import-bounces` laufen lassen, damit die bisherige +Handliste in die zentrale Sperre kommt. + +**Offen:** Schritt 3 (IMAP-Abholung, `webklex/laravel-imap`, freigegeben +15.09.2026) und Schritt 4 (Markierung im Admin, Claim-Seite, Freigabe). diff --git a/tests/Feature/EmailSuppressionTest.php b/tests/Feature/EmailSuppressionTest.php new file mode 100644 index 00000000..24b58952 --- /dev/null +++ b/tests/Feature/EmailSuppressionTest.php @@ -0,0 +1,229 @@ +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())->toBe('user:'.$user->id); +}); + +// ------------------------------------------------- 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')); +});