diff --git a/.gitignore b/.gitignore index d9fa0f0c..c19cf705 100644 --- a/.gitignore +++ b/.gitignore @@ -63,5 +63,8 @@ dev/archive/migration-2026/sql/ # Produktivdaten: API-Aktivitaets-Logs der Altportale (Kundendaten + Alt-API-Keys), analog SQL-Dumps dev/api/ +# Bounce-Rückläufer: echte E-Mail-Adressen, gehören nicht ins Repository. +dev/newsletter/ + # Rechtstext-Quellen des Users (untracked Arbeitsstand) docs/legal/ diff --git a/app/Console/Commands/ImportNewsletterBounces.php b/app/Console/Commands/ImportNewsletterBounces.php new file mode 100644 index 00000000..164febe4 --- /dev/null +++ b/app/Console/Commands/ImportNewsletterBounces.php @@ -0,0 +1,219 @@ +argument('file'); + $path = Str::startsWith($file, '/') ? $file : base_path($file); + + if (! is_file($path)) { + $this->error("Datei nicht gefunden: {$path}"); + + return self::FAILURE; + } + + $emails = $this->extractEmails((string) file_get_contents($path)); + + if ($emails === []) { + $this->warn('Keine E-Mail-Adressen in der Datei gefunden.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $optedOut = 0; + $alreadyOptedOut = 0; + $unsubscribed = 0; + $alreadyUnsubscribed = 0; + $unknown = []; + + foreach ($emails as $email) { + $touchedUsers = $this->suppressUsers($email, $dryRun, $optedOut, $alreadyOptedOut); + $touchedSubscriptions = $this->suppressSubscriptions($email, $dryRun, $unsubscribed, $alreadyUnsubscribed); + + // Unbekannt ist eine Adresse erst, wenn sie in KEINER der beiden + // Listen steht — sonst meldete der Lauf Adressen als unbekannt, die + // er soeben aus der öffentlichen Anmeldung genommen hat. + if (! $touchedUsers && ! $touchedSubscriptions) { + $unknown[] = $email; + } + } + + $prefix = $dryRun ? '[Dry-Run] ' : ''; + $this->info(sprintf( + '%s%d Adressen gelesen: %d abgemeldet, %d waren bereits abgemeldet, %d ohne User-Konto.', + $prefix, + count($emails), + $optedOut, + $alreadyOptedOut, + count($unknown), + )); + $this->info(sprintf( + '%sNewsletter-Anmeldungen: %d abgemeldet, %d waren bereits abgemeldet.', + $prefix, + $unsubscribed, + $alreadyUnsubscribed, + )); + + foreach ($unknown as $email) { + $this->line("Kein User-Konto: {$email}"); + } + + return self::SUCCESS; + } + + /** + * Opt-out für alle Konten mit dieser Adresse. + * + * @return bool ob die Adresse überhaupt einem Konto zugeordnet ist + */ + private function suppressUsers(string $email, bool $dryRun, int &$optedOut, int &$alreadyOptedOut): bool + { + // Groß-/Kleinschreibung ausdrücklich ignorieren, wie in + // User::findByEmail(). Bounce-Listen kommen aus dem Mailserver-Log und + // geben die Adresse so wieder, wie der Absender sie geschrieben hat. + $users = User::query()->whereRaw('LOWER(email) = ?', [$email])->get(); + + foreach ($users as $user) { + if (! $user->wantsEmailNotification(EmailNotificationType::Newsletter)) { + $alreadyOptedOut++; + + continue; + } + + $optedOut++; + + if ($dryRun) { + $this->line("Würde abmelden: {$user->email} (User #{$user->id})"); + + continue; + } + + $user->optOutOfEmailNotification(EmailNotificationType::Newsletter); + + $this->activityLog->record( + ActivityType::EmailUnsubscribe, + sprintf('%s (%s) wegen unzustellbarer Adresse vom Newsletter abgemeldet.', $user->name, $user->email), + user: $user, + meta: ['type' => EmailNotificationType::Newsletter->value, 'action' => 'unsubscribe', 'method' => 'bounce'], + actorLabel: 'System (Bounce-Import)', + ); + } + + return $users->isNotEmpty(); + } + + /** + * Abmeldung der öffentlichen Newsletter-Anmeldung(en) dieser Adresse. + * + * Scope-frei: Die Anmeldung hängt am Portal, der Bounce-Import läuft aber + * auf der Kommandozeile ohne Portal-Kontext und muss alle Portale treffen. + * + * @return bool ob die Adresse überhaupt angemeldet ist + */ + private function suppressSubscriptions(string $email, bool $dryRun, int &$unsubscribed, int &$alreadyUnsubscribed): bool + { + $subscriptions = NewsletterSubscription::query() + ->withoutGlobalScopes() + ->whereRaw('LOWER(email) = ?', [$email]) + ->get(); + + foreach ($subscriptions as $subscription) { + if ($subscription->unsubscribed_at !== null) { + $alreadyUnsubscribed++; + + continue; + } + + $unsubscribed++; + + if ($dryRun) { + $this->line("Würde Anmeldung abmelden: {$subscription->email} ({$subscription->portal->value})"); + + continue; + } + + $subscription->update(['unsubscribed_at' => now()]); + + // Die Abmeldung muss auch beim externen Versender ankommen, sonst + // stellt der weiter zu und der Absenderruf leidet trotzdem. + $this->newsletterSync->syncSubscription($subscription); + + $this->activityLog->record( + ActivityType::EmailUnsubscribe, + sprintf('Newsletter-Anmeldung %s (%s) wegen unzustellbarer Adresse abgemeldet.', $subscription->email, $subscription->portal->value), + user: $subscription->user, + meta: [ + 'type' => EmailNotificationType::Newsletter->value, + 'action' => 'unsubscribe', + 'method' => 'bounce', + 'newsletter_subscription_id' => $subscription->id, + ], + actorLabel: 'System (Bounce-Import)', + ); + } + + return $subscriptions->isNotEmpty(); + } + + /** + * Zieht alle E-Mail-Adressen aus dem Datei-Inhalt — die Datei darf + * Überschriften oder Bounce-Text enthalten, gezählt wird pro Adresse. + * + * @return list + */ + private function extractEmails(string $contents): array + { + preg_match_all('/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/', $contents, $matches); + + return array_values(array_unique(array_map( + static fn (string $email): string => mb_strtolower($email), + $matches[0], + ))); + } +} diff --git a/tests/Feature/ImportNewsletterBouncesTest.php b/tests/Feature/ImportNewsletterBouncesTest.php new file mode 100644 index 00000000..8bbc39bb --- /dev/null +++ b/tests/Feature/ImportNewsletterBouncesTest.php @@ -0,0 +1,174 @@ +create(['email' => 'info@rattanhaus.de']); + $alreadyOut = User::factory()->create(['email' => 'service@auto-expert24.de']); + $alreadyOut->optOutOfEmailNotification(EmailNotificationType::Newsletter); + $untouched = User::factory()->create(); + + $path = bounceFile(<<<'TXT' + Nicht erreichbar + + INFO@rattanhaus.de + service@auto-expert24.de + citations2@locible.xyz + TXT); + + $this->artisan('newsletter:import-bounces', ['file' => $path]) + ->expectsOutputToContain('3 Adressen gelesen: 1 abgemeldet, 1 waren bereits abgemeldet, 1 ohne User-Konto.') + ->expectsOutputToContain('Kein User-Konto: citations2@locible.xyz') + ->assertExitCode(0); + + expect($bounced->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeFalse() + ->and($untouched->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeTrue(); + + // Der Opt-out steht mit Methode „bounce“ im Aktivitäts-Verlauf. + $event = ActivityEvent::query() + ->where('type', ActivityType::EmailUnsubscribe->value) + ->where('user_id', $bounced->id) + ->sole(); + + expect($event->meta['method'])->toBe('bounce'); + + // Zweiter Lauf ist wirkungslos (idempotent, kein Doppel-Eintrag). + $this->artisan('newsletter:import-bounces', ['file' => $path]) + ->expectsOutputToContain('3 Adressen gelesen: 0 abgemeldet, 2 waren bereits abgemeldet, 1 ohne User-Konto.') + ->assertExitCode(0); + + expect(ActivityEvent::query()->where('user_id', $bounced->id)->count())->toBe(1); + + unlink($path); +}); + +test('the bounce import dry run changes nothing', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['email' => 'info@flerbarshop.de']); + + $path = bounceFile("info@flerbarshop.de\n"); + + $this->artisan('newsletter:import-bounces', ['file' => $path, '--dry-run' => true]) + ->expectsOutputToContain('[Dry-Run] 1 Adressen gelesen: 1 abgemeldet') + ->assertExitCode(0); + + expect($user->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeTrue(); + + unlink($path); +}); + +test('the bounce import fails cleanly for a missing file', function () { + /** @var TestCase $this */ + $this->artisan('newsletter:import-bounces', ['file' => '/gibt/es/nicht.md']) + ->assertExitCode(1); +}); + +test('the bounce import also unsubscribes the public newsletter signup', function () { + /** @var TestCase $this */ + // Kein User-Konto — genau der Fall, der bisher nur als „unbekannt“ + // protokolliert und dann weiter angeschrieben wurde. + $subscription = NewsletterSubscription::withoutGlobalScopes()->create([ + 'portal' => Portal::Presseecho->value, + 'email' => 'redaktion@tote-adresse.de', + 'is_confirmed' => true, + 'subscribed_at' => now()->subMonth(), + 'unsubscribed_at' => null, + ]); + + $path = bounceFile("Redaktion@Tote-Adresse.de\n"); + + $this->artisan('newsletter:import-bounces', ['file' => $path]) + ->expectsOutputToContain('Newsletter-Anmeldungen: 1 abgemeldet, 0 waren bereits abgemeldet.') + ->assertExitCode(0); + + expect($subscription->fresh()->unsubscribed_at)->not->toBeNull(); + + // Und die Adresse gilt nicht mehr als „ohne Konto“, weil sie sehr wohl + // erreicht wurde. + $this->artisan('newsletter:import-bounces', ['file' => $path]) + ->expectsOutputToContain('1 Adressen gelesen: 0 abgemeldet, 0 waren bereits abgemeldet, 0 ohne User-Konto.') + ->expectsOutputToContain('Newsletter-Anmeldungen: 0 abgemeldet, 1 waren bereits abgemeldet.') + ->assertExitCode(0); + + unlink($path); +}); + +test('the bounce import reaches every portal of an address', function () { + /** @var TestCase $this */ + // Die Anmeldung ist je Portal eindeutig; eine Adresse kann auf beiden + // Portalen stehen. Der Lauf hat keinen Portal-Kontext und muss beide + // treffen. + foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) { + NewsletterSubscription::withoutGlobalScopes()->create([ + 'portal' => $portal->value, + 'email' => 'beide@tote-adresse.de', + 'is_confirmed' => true, + 'subscribed_at' => now()->subMonth(), + ]); + } + + $path = bounceFile("beide@tote-adresse.de\n"); + + $this->artisan('newsletter:import-bounces', ['file' => $path]) + ->expectsOutputToContain('Newsletter-Anmeldungen: 2 abgemeldet, 0 waren bereits abgemeldet.') + ->assertExitCode(0); + + expect(NewsletterSubscription::withoutGlobalScopes()->whereNull('unsubscribed_at')->count())->toBe(0); + + unlink($path); +}); + +test('the bounce import dry run leaves newsletter signups alone', function () { + /** @var TestCase $this */ + $subscription = NewsletterSubscription::withoutGlobalScopes()->create([ + 'portal' => Portal::Presseecho->value, + 'email' => 'probe@tote-adresse.de', + 'is_confirmed' => true, + 'subscribed_at' => now()->subMonth(), + ]); + + $path = bounceFile("probe@tote-adresse.de\n"); + + $this->artisan('newsletter:import-bounces', ['file' => $path, '--dry-run' => true]) + ->expectsOutputToContain('[Dry-Run] Newsletter-Anmeldungen: 1 abgemeldet') + ->assertExitCode(0); + + expect($subscription->fresh()->unsubscribed_at)->toBeNull(); + + unlink($path); +}); + +test('the bounce import ignores the letter case of the stored address', function () { + /** @var TestCase $this */ + // Die Adresse steht im Konto anders geschrieben als im Bounce-Log. + $user = User::factory()->create(['email' => 'Presse@Gemischte-Schreibung.de']); + + $path = bounceFile("presse@gemischte-schreibung.de\n"); + + $this->artisan('newsletter:import-bounces', ['file' => $path]) + ->expectsOutputToContain('1 Adressen gelesen: 1 abgemeldet') + ->assertExitCode(0); + + expect($user->fresh()->wantsEmailNotification(EmailNotificationType::Newsletter))->toBeFalse(); + + unlink($path); +});