presseportale/app/Console/Commands/ImportNewsletterBounces.php
Kevin Adametz 8bf40ee37e mail: zentrale Bounce-Suppression mit Versand-Gate und Circuit Breaker (Roadmap 1b.6, Schritte 1–2)
email_suppressions/email_bounces als Zustand und Ereignis-Log; ein
MessageSending-Listener streicht gesperrte Empfänger aus JEDER ausgehenden
Mail und setzt den Return-Path. Zweiter Aufnahmepfad: SMTP-Ablehnungen im
Worker — 5.1.x sperrt den Empfänger, Relay-Ablehnungen zählen für den
Circuit Breaker, der den Versand anhält statt gesunde Adressen zu sperren.
X-Pk-Ref-Header auf den Mails, API-Meldungen fallen auf die Firmen-E-Mail
zurück, der Bounce-Import schreibt in die zentrale Liste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 13:48:56 +00:00

230 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 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 = 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<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],
)));
}
}