mail: adressbezogene Abmeldung fuer Empfaenger ohne Konto (Roadmap 1d.3)
Der Abmeldelink in API-Benachrichtigungen zeigte auf das Konto des Einlieferers - ein Klick des Pressekontakts haette connektar abgemeldet. Jetzt traegt der Link die Empfaengeradresse (signiert), der Zustand liegt als Unsubscribed in der Sperrliste. Anmeldelinks, die die Person selbst anfordert, gehen weiter durch; harte Bounces sperren alles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
13716b9c06
commit
9380937562
15 changed files with 362 additions and 20 deletions
113
tests/Feature/AddressUnsubscribeTest.php
Normal file
113
tests/Feature/AddressUnsubscribeTest.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\EmailBounceKind;
|
||||
use App\Enums\EmailNotificationType;
|
||||
use App\Enums\EmailSuppressionReason;
|
||||
use App\Enums\Portal;
|
||||
use App\Mail\MagicLoginLink;
|
||||
use App\Mail\PressReleasePublished;
|
||||
use App\Models\Company;
|
||||
use App\Models\EmailSuppression;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Abmeldung für Empfänger ohne Konto (Roadmap 1d.3): Pressekontakt oder
|
||||
* Firmen-E-Mail einer API-Meldung. Der Zustand liegt in der Sperrliste;
|
||||
* selbst angeforderte Anmeldelinks gehen weiter durch.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
function apiReleaseFor(string $companyEmail): PressRelease
|
||||
{
|
||||
$submitter = User::factory()->create(['is_active' => true, 'email' => 'api@connektar.example']);
|
||||
$company = Company::factory()->create(['portal' => Portal::Businessportal24->value, 'email' => $companyEmail, 'name' => 'Muster GmbH']);
|
||||
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['user_id' => $submitter->id, 'company_id' => $company->id]);
|
||||
PressReleaseStatusLog::query()->create(['press_release_id' => $release->id, 'from_status' => 'draft', 'to_status' => 'review', 'source' => 'api']);
|
||||
|
||||
return $release;
|
||||
}
|
||||
|
||||
test('the notification to a recipient without an account carries an address-based unsubscribe link', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = apiReleaseFor('presse@muster.example');
|
||||
|
||||
$mail = new PressReleasePublished($release->user, $release, 'Muster GmbH', 'presse@muster.example');
|
||||
|
||||
// Vorher zeigte der Link auf das Konto des Einlieferers — ein Klick des
|
||||
// Pressekontakts hätte connektar abgemeldet.
|
||||
expect($mail->unsubscribeUrl)->toContain('/abmelden-adresse?')
|
||||
->and($mail->unsubscribeUrl)->toContain('email=presse%40muster.example')
|
||||
->and($mail->unsubscribeUrl)->not->toContain('/abmelden/'.$release->user_id.'/');
|
||||
|
||||
// Der Einlieferer selbst behält den Konto-Link.
|
||||
$own = new PressReleasePublished($release->user, $release, null, $release->user->email);
|
||||
expect($own->unsubscribeUrl)->toContain('/abmelden/'.$release->user_id.'/');
|
||||
});
|
||||
|
||||
test('clicking the address link records an unsubscribe suppression and blocks further notifications', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = apiReleaseFor('presse@muster.example');
|
||||
$url = URL::signedRoute('email-preferences.unsubscribe-address', ['email' => 'presse@muster.example', 'type' => EmailNotificationType::PressReleasePublished->value]);
|
||||
|
||||
$this->get($url)->assertOk()->assertSeeText('gehen keine Benachrichtigungen mehr')->assertSeeText('p***@muster.example');
|
||||
|
||||
$suppression = EmailSuppression::query()->where('email', 'presse@muster.example')->firstOrFail();
|
||||
expect($suppression->reason)->toBe(EmailSuppressionReason::Unsubscribed)
|
||||
->and($suppression->isActive())->toBeTrue();
|
||||
|
||||
// Nächste Benachrichtigung an diese Firma: keine Mail.
|
||||
app(PressReleaseService::class)->reject(tap($release)->update(['status' => 'review']), 'Grund');
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
test('an unsubscribed address still receives the login link it requests itself', function () {
|
||||
/** @var TestCase $this */
|
||||
apiReleaseFor('presse@muster.example');
|
||||
app(EmailSuppressionService::class)->unsubscribe('presse@muster.example');
|
||||
|
||||
Mail::fake();
|
||||
app(ContactAccessService::class)->requestMagicAccess('presse@muster.example');
|
||||
Mail::assertQueued(MagicLoginLink::class);
|
||||
|
||||
// …ein harter Bounce sperrt dagegen alles.
|
||||
app(EmailSuppressionService::class)->recordBounce('presse@muster.example', EmailBounceKind::Hard, 'imap', 'm-1');
|
||||
expect(app(EmailSuppressionService::class)->isSuppressed('presse@muster.example', includeUnsubscribed: false))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the unsubscribe can be undone from the confirmation page, a tampered link cannot unsubscribe', function () {
|
||||
/** @var TestCase $this */
|
||||
$suppressions = app(EmailSuppressionService::class);
|
||||
$suppressions->unsubscribe('presse@muster.example');
|
||||
|
||||
$this->post(URL::signedRoute('email-preferences.resubscribe-address', ['email' => 'presse@muster.example', 'type' => 'press_release_published']))
|
||||
->assertOk()->assertSeeText('wieder Benachrichtigungen');
|
||||
expect($suppressions->isSuppressed('presse@muster.example'))->toBeFalse();
|
||||
|
||||
$this->get(route('email-preferences.unsubscribe-address', ['email' => 'opfer@example.org', 'type' => 'press_release_published', 'signature' => 'x']))
|
||||
->assertOk()->assertSeeText('Link ungültig');
|
||||
expect($suppressions->isSuppressed('opfer@example.org'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a hard-bounce suppression is not downgraded to an unsubscribe', function () {
|
||||
/** @var TestCase $this */
|
||||
$suppressions = app(EmailSuppressionService::class);
|
||||
$suppressions->recordBounce('tot@muster.example', EmailBounceKind::Hard, 'imap', 'm-2');
|
||||
|
||||
$suppressions->unsubscribe('tot@muster.example');
|
||||
|
||||
expect(EmailSuppression::query()->where('email', 'tot@muster.example')->first()->reason)->toBe(EmailSuppressionReason::HardBounce)
|
||||
->and($suppressions->resubscribe('tot@muster.example'))->toBeFalse();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue