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) { // 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); // 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 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, 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 = EmailQuery::whereEmail(User::query(), $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 = EmailQuery::whereEmail(NewsletterSubscription::query()->withoutGlobalScopes(), $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], ))); } }