presseportale/app/Console/Commands/ImportNewsletterBounces.php
Kevin Adametz 72432fdb82 web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)
Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben

F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope
auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und
Medienpfade sowie die PressRelease-Helfer schreiben jetzt
withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht
kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit
Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter
und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst
verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein.
Der Vorschau-Test prüfte bisher forceDelete statt delete.

F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen
Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht
auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER.
Neuer Index contacts_email_idx.

F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei
Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die
Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt-
Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache
vorher nicht und traf den Fehler nie.

F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst
(ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung
gewinnt die Erstquelle.

F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss
aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren
Vertrag.

F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann
Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht
als verlorenes Logo.

Block 4 — KI-Prüfung und Scheduler

F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei
Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen
(ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler
veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum
aktuellen Text passt, sondern plant eine Prüfung mit Routing ein.

F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period).
Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der
Reset ist idempotent und lässt den laufenden Monat in Ruhe.

F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen
Fehler-Exit-Code, damit der Failure-Hook greift.

F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt.

F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:40:48 +00:00

229 lines
8.4 KiB
PHP

<?php
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 App\Support\EmailQuery;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
/**
* Liest eine Datei mit unzustellbaren E-Mail-Adressen (Bounce-Rückläufer,
* z. B. dev/newsletter/mail-address.md) und nimmt die betroffenen Adressen aus
* allen Mailstrecken.
*
* Es gibt zwei getrennte Empfängerlisten, und eine Bounce-Adresse muss aus
* BEIDEN verschwinden:
*
* - `users` — Grundmenge der internen Kampagnen (Admin ▸ Newsletter, siehe
* CampaignRecipients). Hier greift das Opt-out je Benachrichtigungsart.
* - `newsletter_subscriptions` — die öffentliche Anmeldung auf den Portalen,
* mit eigenem `unsubscribed_at` und eigener Synchronisierung nach außen.
* Diese Liste hängt nicht am Konto: Die meisten Einträge haben gar keinen
* User, wurden hier also bislang gar nicht erreicht.
*
* Warum das zählt: Wiederholte Zustellversuche an tote Adressen kosten den
* Absenderruf (bei All-Inkl 554-Spam-Pattern-Ablehnungen), und der trifft
* zuerst die Magic Links — also genau den Weg, über den Newsrooms beansprucht
* werden sollen.
*/
class ImportNewsletterBounces extends Command
{
protected $signature = 'newsletter:import-bounces
{file=dev/newsletter/mail-address.md : Datei mit den Bounce-Adressen}
{--dry-run : Nur anzeigen, was passieren würde}';
protected $description = 'Nimmt unzustellbare Adressen aus einer Bounce-Liste aus Konten und Newsletter-Anmeldungen.';
public function __construct(
private readonly ActivityLog $activityLog,
private readonly NewsletterSyncService $newsletterSync,
private readonly EmailSuppressionService $suppressions,
) {
parent::__construct();
}
public function handle(): int
{
$file = (string) $this->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<string>
*/
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],
)));
}
}