From 8b4f2de3841f3b86efd23b0de1b92d28562ea16b Mon Sep 17 00:00:00 2001 From: Mark Chaney Date: Wed, 26 Aug 2026 01:55:35 -0400 Subject: [PATCH 1/2] Consult the host's mail policy on preview and attachment routes canManageMails() answers whether a user may use the mail log at all. Hosts that need a per-mail answer as well - a multi-tenant application where a user may only open mails belonging to their own tenant - have no hook: both controllers resolve the mail by id and serve it. When the host has registered a policy for the mail model, authorize the view ability against the requested mail before serving the preview or an attachment. Hosts without a policy are unaffected. --- README.md | 16 ++++++++++++++ src/Controllers/MailDownloadController.php | 7 ++++++ src/Controllers/MailPreviewController.php | 7 ++++++ tests/Fixtures/DenyOddMailPolicy.php | 13 +++++++++++ tests/MailRouteSecurityTest.php | 25 ++++++++++++++++++++++ 5 files changed, 68 insertions(+) create mode 100644 tests/Fixtures/DenyOddMailPolicy.php diff --git a/README.md b/README.md index 95e2303..a2496c5 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,22 @@ The same `canManageMails()` check protects mail previews and attachment downloads. Attachments can only be downloaded through the mail record they belong to, and previews run in a sandboxed iframe. +`canManageMails()` decides whether a user may use the mail log at all. If you +also need to decide per mail — for example in a multi-tenant application where +a user may only open mails that belong to their own tenant — register a policy +for your mail model. When one exists, the preview and attachment routes +additionally authorize the `view` ability against the requested mail: + +```php +class MailPolicy +{ + public function view(User $user, Mail $mail): bool + { + return $user->tenant_id === $mail->tenant_id; + } +} +``` + ### Tenant middleware and route protection If you want to protect the mail routes with your tenant middleware, add them to `authenticatedTenantRoutes()`: diff --git a/src/Controllers/MailDownloadController.php b/src/Controllers/MailDownloadController.php index c2e5e0f..4364626 100644 --- a/src/Controllers/MailDownloadController.php +++ b/src/Controllers/MailDownloadController.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Gate; use Symfony\Component\HttpFoundation\StreamedResponse; class MailDownloadController extends Controller @@ -15,6 +16,12 @@ public function __invoke(Request $request): StreamedResponse $mail = $mailModel::findOrFail($request->route('mail')); + // canManageMails() answers "may this user use the mail log at all"; a host with a policy + // for the mail model also gets to answer "may they see this particular mail". + if (Gate::getPolicyFor($mail) !== null) { + Gate::authorize('view', $mail); + } + $attachment = $mail->attachments()->findOrFail($request->route('attachment')); return $attachment->downloadFileFromStorage(); diff --git a/src/Controllers/MailPreviewController.php b/src/Controllers/MailPreviewController.php index 833d1ab..e838e66 100644 --- a/src/Controllers/MailPreviewController.php +++ b/src/Controllers/MailPreviewController.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Gate; use Symfony\Component\HttpFoundation\Response; class MailPreviewController extends Controller @@ -15,6 +16,12 @@ public function __invoke(Request $request): Response $mail = $mailModel::findOrFail($request->route('mail')); + // canManageMails() answers "may this user use the mail log at all"; a host with a policy + // for the mail model also gets to answer "may they see this particular mail". + if (Gate::getPolicyFor($mail) !== null) { + Gate::authorize('view', $mail); + } + return response($mail->html, 200, [ 'Content-Type' => 'text/html; charset=UTF-8', 'Content-Security-Policy' => "sandbox; frame-ancestors 'self'", diff --git a/tests/Fixtures/DenyOddMailPolicy.php b/tests/Fixtures/DenyOddMailPolicy.php new file mode 100644 index 0000000..83531b3 --- /dev/null +++ b/tests/Fixtures/DenyOddMailPolicy.php @@ -0,0 +1,13 @@ +getKey() % 2 === 0; + } +} diff --git a/tests/MailRouteSecurityTest.php b/tests/MailRouteSecurityTest.php index 60f6c50..86f9d62 100644 --- a/tests/MailRouteSecurityTest.php +++ b/tests/MailRouteSecurityTest.php @@ -3,7 +3,9 @@ use Backstage\Mails\Laravel\Models\Mail; use Backstage\Mails\Laravel\Models\MailAttachment; use Backstage\Mails\MailsPlugin; +use Backstage\Mails\Tests\Fixtures\DenyOddMailPolicy; use Backstage\Mails\Tests\Fixtures\User; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -167,3 +169,26 @@ function downloadUrl(Mail $mail, MailAttachment $attachment): string ->toContain('referrerpolicy="no-referrer"') ->not->toContain('src="'); }); + +it('consults the host mail policy for the preview when one is registered', function () { + Gate::policy(Mail::class, DenyOddMailPolicy::class); + MailsPlugin::get()->canManageMails(true); + + $user = mailUser(); + $denied = Mail::factory()->create(['id' => 1, 'html' => '

secret

']); + $allowed = Mail::factory()->create(['id' => 2, 'html' => '

visible

']); + + $this->actingAs($user)->get(previewUrl($denied))->assertForbidden(); + $this->actingAs($user)->get(previewUrl($allowed))->assertSuccessful()->assertSee('visible'); +}); + +it('consults the host mail policy for attachment downloads when one is registered', function () { + Storage::fake('local'); + Gate::policy(Mail::class, DenyOddMailPolicy::class); + MailsPlugin::get()->canManageMails(true); + + $denied = Mail::factory()->create(['id' => 1]); + $attachment = attachmentFor($denied); + + $this->actingAs(mailUser())->get(downloadUrl($denied, $attachment))->assertForbidden(); +}); From 0d6b181dbda5dadecacfd4f5c40f8404e3172bfd Mon Sep 17 00:00:00 2001 From: Mark Chaney Date: Fri, 25 Sep 2026 02:17:58 -0400 Subject: [PATCH 2/2] Prove the mail policy receives the authenticated user. The even/odd fixture never looked at the user, so it did not show that a host policy can allow one person and deny another for the same mail. Apps that never register a policy stay unchanged. Co-authored-by: Cursor --- README.md | 11 +++--- ...Policy.php => AllowSpecificUserPolicy.php} | 4 +- tests/MailRouteSecurityTest.php | 38 ++++++++++++------- 3 files changed, 33 insertions(+), 20 deletions(-) rename tests/Fixtures/{DenyOddMailPolicy.php => AllowSpecificUserPolicy.php} (65%) diff --git a/README.md b/README.md index a2496c5..91160c0 100644 --- a/README.md +++ b/README.md @@ -125,11 +125,12 @@ The same `canManageMails()` check protects mail previews and attachment downloads. Attachments can only be downloaded through the mail record they belong to, and previews run in a sandboxed iframe. -`canManageMails()` decides whether a user may use the mail log at all. If you -also need to decide per mail — for example in a multi-tenant application where -a user may only open mails that belong to their own tenant — register a policy -for your mail model. When one exists, the preview and attachment routes -additionally authorize the `view` ability against the requested mail: +`canManageMails()` decides whether a user may use the mail log at all. Most +applications stop there, and nothing else changes. If you also need to decide +per mail — for example in a multi-tenant application where a user may only open +mails that belong to their own tenant — register a policy for your mail model. +When one exists, the preview and attachment routes authorize `view` for the +authenticated user and that mail: ```php class MailPolicy diff --git a/tests/Fixtures/DenyOddMailPolicy.php b/tests/Fixtures/AllowSpecificUserPolicy.php similarity index 65% rename from tests/Fixtures/DenyOddMailPolicy.php rename to tests/Fixtures/AllowSpecificUserPolicy.php index 83531b3..a2482ab 100644 --- a/tests/Fixtures/DenyOddMailPolicy.php +++ b/tests/Fixtures/AllowSpecificUserPolicy.php @@ -4,10 +4,10 @@ use Backstage\Mails\Laravel\Models\Mail; -class DenyOddMailPolicy +class AllowSpecificUserPolicy { public function view(User $user, Mail $mail): bool { - return $mail->getKey() % 2 === 0; + return $user->email === 'allowed@example.com'; } } diff --git a/tests/MailRouteSecurityTest.php b/tests/MailRouteSecurityTest.php index 86f9d62..59d9137 100644 --- a/tests/MailRouteSecurityTest.php +++ b/tests/MailRouteSecurityTest.php @@ -3,18 +3,18 @@ use Backstage\Mails\Laravel\Models\Mail; use Backstage\Mails\Laravel\Models\MailAttachment; use Backstage\Mails\MailsPlugin; -use Backstage\Mails\Tests\Fixtures\DenyOddMailPolicy; +use Backstage\Mails\Tests\Fixtures\AllowSpecificUserPolicy; use Backstage\Mails\Tests\Fixtures\User; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -function mailUser(): User +function mailUser(string $email = 'test@example.com'): User { return User::create([ 'name' => 'Test User', - 'email' => 'test@example.com', + 'email' => $email, 'password' => Hash::make('password'), ]); } @@ -171,24 +171,36 @@ function downloadUrl(Mail $mail, MailAttachment $attachment): string }); it('consults the host mail policy for the preview when one is registered', function () { - Gate::policy(Mail::class, DenyOddMailPolicy::class); + Gate::policy(Mail::class, AllowSpecificUserPolicy::class); MailsPlugin::get()->canManageMails(true); - $user = mailUser(); - $denied = Mail::factory()->create(['id' => 1, 'html' => '

secret

']); - $allowed = Mail::factory()->create(['id' => 2, 'html' => '

visible

']); + $mail = Mail::factory()->create(['html' => '

visible

']); - $this->actingAs($user)->get(previewUrl($denied))->assertForbidden(); - $this->actingAs($user)->get(previewUrl($allowed))->assertSuccessful()->assertSee('visible'); + $this->actingAs(mailUser('denied@example.com')) + ->get(previewUrl($mail)) + ->assertForbidden(); + + $this->actingAs(mailUser('allowed@example.com')) + ->get(previewUrl($mail)) + ->assertSuccessful() + ->assertSee('visible'); }); it('consults the host mail policy for attachment downloads when one is registered', function () { Storage::fake('local'); - Gate::policy(Mail::class, DenyOddMailPolicy::class); + Gate::policy(Mail::class, AllowSpecificUserPolicy::class); MailsPlugin::get()->canManageMails(true); - $denied = Mail::factory()->create(['id' => 1]); - $attachment = attachmentFor($denied); + $mail = Mail::factory()->create(); + $attachment = attachmentFor($mail); + + $this->actingAs(mailUser('denied@example.com')) + ->get(downloadUrl($mail, $attachment)) + ->assertForbidden(); + + $response = $this->actingAs(mailUser('allowed@example.com')) + ->get(downloadUrl($mail, $attachment)) + ->assertSuccessful(); - $this->actingAs(mailUser())->get(downloadUrl($denied, $attachment))->assertForbidden(); + expect($response->streamedContent())->toBe('CONFIDENTIAL'); });