web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt() für Sitemap-lastmod, dateModified und article:modified_time - R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen - R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt — hart wie weich — nur ein gültig signierter Rückläufer, unsignierte standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy) - R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und Unmerge, assignAll setzt veraltete Verweise zurück - R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach 395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert, Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst - N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap - OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1d417f87f7
commit
c2e0b85242
48 changed files with 1676 additions and 134 deletions
|
|
@ -5,6 +5,7 @@ use App\Enums\EmailBounceKind;
|
|||
use App\Models\ActivityEvent;
|
||||
use App\Models\EmailBounce;
|
||||
use App\Services\Mail\BounceMailProcessor;
|
||||
use App\Services\Mail\BounceReference;
|
||||
use App\Services\Mail\DsnParser;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -18,6 +19,19 @@ function bounceFixture(string $name): string
|
|||
return (string) file_get_contents(base_path("tests/Fixtures/bounces/{$name}.eml"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture, deren X-Pk-Ref wie beim echten Versand für `$recipient` signiert
|
||||
* ist (Umsetzungsaudit 29.09.2026, R-04).
|
||||
*/
|
||||
function signedBounceFixture(string $name, string $recipient): string
|
||||
{
|
||||
return (string) preg_replace_callback(
|
||||
'/^X-Pk-Ref: (.+)$/m',
|
||||
fn (array $m): string => 'X-Pk-Ref: '.app(BounceReference::class)->sign(trim($m[1]), [$recipient]),
|
||||
bounceFixture($name),
|
||||
);
|
||||
}
|
||||
|
||||
test('a postfix hard bounce yields recipient, status, diagnostic and our reference header', function () {
|
||||
/** @var TestCase $this */
|
||||
$report = app(DsnParser::class)->parse(bounceFixture('hard-5.1.1'));
|
||||
|
|
@ -35,7 +49,7 @@ test('a postfix hard bounce yields recipient, status, diagnostic and our referen
|
|||
|
||||
test('processing a hard bounce suppresses the address and keeps the reference', function () {
|
||||
/** @var TestCase $this */
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
$result = app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and($result['bounces'])->toBe(1)
|
||||
|
|
@ -44,13 +58,13 @@ test('processing a hard bounce suppresses the address and keeps the reference',
|
|||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard);
|
||||
|
||||
// Zweiter Durchlauf derselben Mail (abgebrochener Lauf): nichts doppelt.
|
||||
app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example'));
|
||||
expect(EmailBounce::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a delayed report counts as a soft bounce and lower-case field names are fine', function () {
|
||||
/** @var TestCase $this */
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1'));
|
||||
$result = app(BounceMailProcessor::class)->process(signedBounceFixture('delayed-4.4.1', 'voll@example.test'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Soft)
|
||||
|
|
@ -89,7 +103,7 @@ test('the utf-8 dsn variant of rfc 6533 is read like a classic report', function
|
|||
->and($report->recipients[0]['status'])->toBe('5.0.0')
|
||||
->and($report->reference)->toBe('user:4711');
|
||||
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('global-delivery-status'));
|
||||
$result = app(BounceMailProcessor::class)->process(signedBounceFixture('global-delivery-status', 'weiterleitung@example.net'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and(EmailBounce::query()->first()->kind)->toBe(EmailBounceKind::Hard);
|
||||
|
|
@ -104,10 +118,23 @@ test('the free-text notice of our own relay is read and never mistaken for an au
|
|||
$result = app(BounceMailProcessor::class)->process(bounceFixture('kas-antispam'));
|
||||
$suppressions = app(EmailSuppressionService::class);
|
||||
|
||||
// Der Freitext trägt keine Original-Header, also keine signierte
|
||||
// Referenz: nur zur Sichtung protokolliert, zählt auf keinen
|
||||
// Schwellwert (R-04, Umsetzungsaudit 02.10.2026, O01).
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and($result['bounces'])->toBe(2)
|
||||
->and($suppressions->isSuppressed('presse@example.net'))->toBeTrue()
|
||||
->and($suppressions->isSuppressed('info@example.org'))->toBeTrue();
|
||||
->and(EmailBounce::query()->where('email', 'presse@example.net')->value('kind'))->toBe(EmailBounceKind::Policy)
|
||||
->and($suppressions->isSuppressed('presse@example.net'))->toBeFalse();
|
||||
|
||||
// Schwellwert-Fenster (7 Tage) relativ zum Datum der Fixture.
|
||||
$this->travelTo('2026-09-03 12:00:00');
|
||||
|
||||
foreach ([2, 3] as $i) {
|
||||
app(BounceMailProcessor::class)->process(str_replace('844FE5F41C65@', '844FE5F41C6'.$i.'@', bounceFixture('kas-antispam')));
|
||||
}
|
||||
|
||||
expect($suppressions->isSuppressed('presse@example.net'))->toBeFalse()
|
||||
->and($suppressions->isSuppressed('info@example.org'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('policy rejections and full mailboxes never count hard', function () {
|
||||
|
|
@ -124,7 +151,7 @@ test('policy rejections and full mailboxes never count hard', function () {
|
|||
$quota = str_replace(
|
||||
['Status: 5.7.1', '554 5.7.1 <pr@example.net>: Relay access denied', '<20260915120000.ABC@'],
|
||||
['Status: 5.2.2', '552 Requested mail action aborted: exceeded storage allocation', '<20260915120001.ABC@'],
|
||||
bounceFixture('policy-5.7.1'),
|
||||
signedBounceFixture('policy-5.7.1', 'pr@example.net'),
|
||||
);
|
||||
app(BounceMailProcessor::class)->process($quota);
|
||||
|
||||
|
|
@ -153,3 +180,110 @@ test('a policy rejection is logged but never counts toward suppression', functio
|
|||
expect(EmailBounce::query()->where('kind', EmailBounceKind::Policy->value)->count())->toBe(4)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('pr@example.net'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a forged report without our signature never suppresses hard', function () {
|
||||
/** @var TestCase $this */
|
||||
// Jeder kann ein multipart/report ans Return-Path-Postfach schicken.
|
||||
$result = app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
|
||||
expect($result['folder'])->toBe(BounceMailProcessor::Processed)
|
||||
->and(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Policy)
|
||||
->and(EmailBounce::query()->sole()->reference)->toBe('pm:4711:company:815 (unsigniert)')
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a report with a wrong or foreign signature is parked for review', function () {
|
||||
/** @var TestCase $this */
|
||||
// Signatur einer echten Mail an den Angreifer selbst, auf die Opferadresse umgeschrieben.
|
||||
$replayed = signedBounceFixture('hard-5.1.1', 'angreifer@example.test');
|
||||
$result = app(BounceMailProcessor::class)->process($replayed);
|
||||
|
||||
expect($result['bounces'])->toBe(1)
|
||||
->and(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Policy)
|
||||
->and(EmailBounce::query()->sole()->reference)->toBe('pm:4711:company:815 (Signatur ungültig)')
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
|
||||
|
||||
// Beliebig oft wiederholt: Richtlinie zählt auf keinen Schwellwert.
|
||||
foreach (range(1, 3) as $i) {
|
||||
app(BounceMailProcessor::class)->process(str_replace('<20260915071233.ABC123@', '<2026091507123'.$i.'.ABC123@', $replayed));
|
||||
}
|
||||
|
||||
expect(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('unsigned reports only count when the operator opts in, and then never hard', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('bounces.unsigned_kind', 'soft');
|
||||
|
||||
app(BounceMailProcessor::class)->process(bounceFixture('hard-5.1.1'));
|
||||
|
||||
expect(EmailBounce::query()->sole()->kind)->toBe(EmailBounceKind::Soft)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('repeated unsigned or wrongly signed soft reports never suppress an address', function (string $variant) {
|
||||
/** @var TestCase $this */
|
||||
// Umsetzungsaudit 02.10.2026, O01: Der weiche Weg (delayed, failed 4.x)
|
||||
// lief an der Signaturprüfung vorbei — drei gefälschte Berichte in
|
||||
// sieben Tagen sperrten jede beliebige Adresse.
|
||||
$raw = match ($variant) {
|
||||
'delayed, unsigned' => bounceFixture('delayed-4.4.1'),
|
||||
'delayed, foreign signature' => signedBounceFixture('delayed-4.4.1', 'angreifer@example.test'),
|
||||
'failed 4.x, unsigned' => str_replace(['Action: delayed', 'action: delayed'], ['Action: failed', 'action: failed'], bounceFixture('delayed-4.4.1')),
|
||||
};
|
||||
|
||||
$this->travelTo('2026-09-17 12:00:00');
|
||||
|
||||
foreach (range(1, 4) as $i) {
|
||||
app(BounceMailProcessor::class)->process(str_replace('<delay-1@', '<delay-'.$i.'0@', $raw));
|
||||
}
|
||||
|
||||
expect(EmailBounce::query()->count())->toBe(4)
|
||||
->and(EmailBounce::query()->where('kind', '!=', EmailBounceKind::Policy->value)->count())->toBe(0)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeFalse();
|
||||
})->with(['delayed, unsigned', 'delayed, foreign signature', 'failed 4.x, unsigned']);
|
||||
|
||||
test('validly signed soft reports still suppress at the threshold', function () {
|
||||
/** @var TestCase $this */
|
||||
// Schwellwert-Fenster (7 Tage) relativ zum Datum der Fixture.
|
||||
$this->travelTo('2026-09-17 12:00:00');
|
||||
|
||||
foreach (range(1, 3) as $i) {
|
||||
app(BounceMailProcessor::class)->process(str_replace('<delay-1@', '<delay-'.$i.'0@', signedBounceFixture('delayed-4.4.1', 'voll@example.test')));
|
||||
}
|
||||
|
||||
expect(EmailBounce::query()->where('kind', EmailBounceKind::Soft->value)->count())->toBe(3)
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('voll@example.test'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the reference signature is bound to the recipient', function () {
|
||||
$references = app(BounceReference::class);
|
||||
$header = $references->sign('user:5', ['A@Example.test', 'b@example.test']);
|
||||
|
||||
expect($header)->toStartWith('user:5; s=')
|
||||
->and($references->plain($header))->toBe('user:5')
|
||||
->and($references->verify($header, 'a@example.test'))->toBeTrue()
|
||||
->and($references->verify($header, 'b@example.test'))->toBeTrue()
|
||||
->and($references->verify($header, 'c@example.test'))->toBeFalse()
|
||||
->and($references->verify(str_replace('user:5', 'user:6', $header), 'a@example.test'))->toBeFalse()
|
||||
->and($references->verify('user:5', 'a@example.test'))->toBeFalse()
|
||||
->and($references->isSigned('user:5'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('bounce events are purged after the retention period while the suppression stays', function () {
|
||||
/** @var TestCase $this */
|
||||
app(BounceMailProcessor::class)->process(signedBounceFixture('hard-5.1.1', 'presse@alte-firma.example'));
|
||||
app(BounceMailProcessor::class)->process(bounceFixture('delayed-4.4.1'));
|
||||
EmailBounce::query()->where('email', 'presse@alte-firma.example')->update(['created_at' => now()->subDays(400)]);
|
||||
|
||||
$this->artisan('conversion:purge-events', ['--dry-run' => true])
|
||||
->expectsOutputToContain('[Dry-Run] 1 Rückläufer-Ereignisse')
|
||||
->assertSuccessful();
|
||||
expect(EmailBounce::query()->count())->toBe(2);
|
||||
|
||||
// Umsetzungsaudit 29.09.2026, R-10: Adresse und Diagnosetext nach 395 Tagen weg.
|
||||
$this->artisan('conversion:purge-events')->assertSuccessful();
|
||||
|
||||
expect(EmailBounce::query()->pluck('email')->all())->toBe(['voll@example.test'])
|
||||
->and(app(EmailSuppressionService::class)->isSuppressed('presse@alte-firma.example'))->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue