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>
230 lines
8.4 KiB
PHP
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],
|
|
)));
|
|
}
|
|
}
|