newsletter: Unzustellbare Adressen aus beiden Empfängerlisten nehmen
newsletter:import-bounces liest eine Bounce-Liste und nimmt die betroffenen Adressen aus jeder künftigen Mailstrecke. Wiederholte Zustellversuche an tote Adressen kosten den Absenderruf — bei All-Inkl in Form von 554-Spam-Pattern-Ablehnungen — und der trifft zuerst die Magic Links, also genau den Weg, über den Newsrooms künftig beansprucht werden sollen. Es gibt zwei getrennte Empfängerlisten, und eine tote Adresse muss aus beiden verschwinden: `users` als Grundmenge der internen Kampagnen und `newsletter_subscriptions` als öffentliche Anmeldung auf den Portalen. Die zweite hängt nicht am Konto — die meisten Einträge dort haben gar keinen User und wären sonst gar nicht erreicht worden. Sie bekommt unsubscribed_at und läuft durch den NewsletterSyncService, damit die Abmeldung auch beim externen Versender ankommt. Als "ohne User-Konto" gilt eine Adresse erst, wenn sie in keiner der beiden Listen steht. Verglichen wird ohne Rücksicht auf Groß- und Kleinschreibung: Bounce-Listen kommen aus dem Mailserver-Log und geben die Adresse so wieder, wie der Absender sie geschrieben hat. Die Rückläufer-Datei selbst bleibt lokal — sie enthält echte Adressen und gehört wie die Alt-API-Logs nicht ins Repository. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
982de2aba2
commit
e634af0330
3 changed files with 396 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
219
app/Console/Commands/ImportNewsletterBounces.php
Normal file
219
app/Console/Commands/ImportNewsletterBounces.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\EmailNotificationType;
|
||||
use App\Models\NewsletterSubscription;
|
||||
use App\Models\User;
|
||||
use App\Services\ActivityLog;
|
||||
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,
|
||||
) {
|
||||
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) {
|
||||
$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<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],
|
||||
)));
|
||||
}
|
||||
}
|
||||
174
tests/Feature/ImportNewsletterBouncesTest.php
Normal file
174
tests/Feature/ImportNewsletterBouncesTest.php
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\EmailNotificationType;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\NewsletterSubscription;
|
||||
use App\Models\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* newsletter:import-bounces — Bounce-Liste einlesen und betroffene User
|
||||
* per Opt-out aus künftigen Newsletter-Kampagnen nehmen.
|
||||
*/
|
||||
function bounceFile(string $contents): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'bounces');
|
||||
file_put_contents($path, $contents);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
test('the bounce import opts out matching users and reports unknown addresses', function () {
|
||||
/** @var TestCase $this */
|
||||
$bounced = User::factory()->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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue