presseportale/tests/Feature/ImportNewsletterBouncesTest.php
Kevin Adametz e634af0330 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>
2026-09-01 11:07:11 +00:00

174 lines
6.5 KiB
PHP

<?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);
});