Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.DS_Store
.idea
.phpunit.cache
.phpunit.result.cache
.vscode
build
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,25 @@ Optionally, you can publish the views using
php artisan vendor:publish --tag="mails-views"
```

Add the routes to the PanelProvider using the `routes()` method, like this:
Add the routes to the PanelProvider using the `authenticatedRoutes()` method, like this:

```php
use Backstage\Mails\Facades\Mails;

public function panel(Panel $panel): Panel
{
return $panel
->routes(fn () => Mails::routes());
->authenticatedRoutes(fn () => Mails::routes());
}
```

> [!NOTE]
> `authenticatedRoutes()` registers the routes inside the panel's authentication
> middleware. The preview and attachment routes also enforce authentication and
> the `canManageMails()` check themselves, so they stay protected even if you
> register them with `routes()` — but `authenticatedRoutes()` is the correct
> place for them.

Then add the plugin to your `PanelProvider`

```php
Expand Down Expand Up @@ -116,9 +123,14 @@ $panel

This example demonstrates how to combine role-based and permission-based access control, providing a more robust and flexible approach to managing access to mail resources.

The `canManageMails()` check also guards the mail preview and attachment download
routes. Unauthenticated visitors are redirected to the panel login, authenticated
users without permission receive a 403, and attachments are only served through
the mail record they belong to.

### Tenant middleware and route protection

If you want to protect the mail routes with your (tenant) middleware, you can do so by adding the routes to the `tenantRoutes`:
If you want to protect the mail routes with your (tenant) middleware, you can do so by adding the routes to the `authenticatedTenantRoutes`:

```php
use Backstage\Mails\MailsPlugin;
Expand All @@ -128,7 +140,7 @@ public function panel(Panel $panel): Panel
{
return $panel
->plugin(MailsPlugin::make())
->tenantRoutes(fn() => Mails::routes());
->authenticatedTenantRoutes(fn () => Mails::routes());
}
```

Expand Down
17 changes: 6 additions & 11 deletions src/Controllers/MailDownloadController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,19 @@

namespace Backstage\Mails\Controllers;

use Backstage\Mails\Laravel\Models\MailAttachment;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Config;

class MailDownloadController extends Controller
{
public function __invoke(...$arguments)
public function __invoke(Request $request)
{
if (count($arguments) === 4) {
[$tenant, $mail, $attachment, $filename] = $arguments;
} else {
[$mail, $attachment, $filename] = $arguments;
$tenant = null;
}
$mailModel = Config::get('mails.models.mail');

$attachmentModel = Config::get('mails.models.attachment');
/** @var MailAttachment $attachment */
$attachment = $attachmentModel::find($attachment);
$mail = $mailModel::findOrFail($request->route('mail'));

$attachment = $mail->attachments()->findOrFail($request->route('attachment'));

return $attachment->downloadFileFromStorage();
}
Expand Down
13 changes: 9 additions & 4 deletions src/Controllers/MailPreviewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@

namespace Backstage\Mails\Controllers;

use Backstage\Mails\Laravel\Models\Mail;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Config;

class MailPreviewController extends Controller
{
public function __invoke(Request $request)
{
/** @var Mail $mail */
$mail = Mail::find($request->mail);
$mailModel = Config::get('mails.models.mail');

return response($mail->html);
$mail = $mailModel::findOrFail($request->route('mail'));

return response($mail->html, 200, [
'Content-Type' => 'text/html; charset=UTF-8',
'X-Content-Type-Options' => 'nosniff',
'Content-Security-Policy' => "frame-ancestors 'self'",
]);
}
}
23 changes: 23 additions & 0 deletions src/Http/Middleware/EnsureUserCanManageMails.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Backstage\Mails\Http\Middleware;

use Backstage\Mails\MailsPlugin;
use Closure;
use Filament\Facades\Filament;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureUserCanManageMails
{
public function handle(Request $request, Closure $next): Response
{
$panel = Filament::getCurrentPanel();

abort_if($panel === null || ! $panel->hasPlugin('mails'), 403);

abort_unless(MailsPlugin::get()->userCanManageMails(), 403);

return $next($request);
}
}
14 changes: 12 additions & 2 deletions src/Mails.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,23 @@

use Backstage\Mails\Controllers\MailDownloadController;
use Backstage\Mails\Controllers\MailPreviewController;
use Backstage\Mails\Http\Middleware\EnsureUserCanManageMails;
use Filament\Http\Middleware\Authenticate;
use Illuminate\Support\Facades\Route;

class Mails
{
public static function routes(): void
{
Route::get('mails/{mail}/preview', MailPreviewController::class)->name('mails.preview');
Route::get('mails/{mail}/attachment/{attachment}/{filename}', MailDownloadController::class)->name('mails.attachment.download');
Route::middleware([
Authenticate::class,
EnsureUserCanManageMails::class,
])->group(function (): void {
Route::get('mails/{mail}/preview', MailPreviewController::class)
->name('mails.preview');

Route::get('mails/{mail}/attachment/{attachment}/{filename}', MailDownloadController::class)
->name('mails.attachment.download');
});
}
}
4 changes: 4 additions & 0 deletions tests/Fixtures/TestPanelProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Backstage\Mails\Tests\Fixtures;

use Backstage\Mails\Mails;
use Backstage\Mails\MailsPlugin;
use Filament\Panel;
use Filament\PanelProvider;
Expand All @@ -14,7 +15,10 @@ public function panel(Panel $panel): Panel
->default()
->id('admin')
->path('admin')
->login()
->authGuard('web')
->topNavigation()
->routes(fn () => Mails::routes())
->plugin(MailsPlugin::make());
}
}
19 changes: 19 additions & 0 deletions tests/Fixtures/User.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Backstage\Mails\Tests\Fixtures;

use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable implements FilamentUser
{
protected $table = 'users';

protected $guarded = [];

public function canAccessPanel(Panel $panel): bool
{
return true;
}
}
149 changes: 149 additions & 0 deletions tests/MailRouteSecurityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

use Backstage\Mails\Laravel\Models\Mail;
use Backstage\Mails\MailsPlugin;
use Backstage\Mails\Tests\Fixtures\User;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

function mailUser(): User
{
return User::create([
'name' => 'Test User',
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
}

function attachmentFor(Mail $mail, string $filename = 'invoice.pdf', string $contents = 'CONFIDENTIAL')
{
$attachment = $mail->attachments()->create([
'uuid' => (string) Str::uuid(),
'disk' => 'local',
'filename' => $filename,
'mime' => 'application/pdf',
'inline' => false,
'size' => strlen($contents),
]);

Storage::disk('local')->put($attachment->storagePath, $contents);

return $attachment;
}

function previewUrl(Mail $mail): string
{
return route('filament.admin.mails.preview', ['mail' => $mail->id]);
}

function downloadUrl(Mail $mail, $attachment): string
{
return route('filament.admin.mails.attachment.download', [
'mail' => $mail->id,
'attachment' => $attachment->id,
'filename' => $attachment->filename,
]);
}

it('redirects a guest away from the mail preview', function () {
$mail = Mail::factory()->create(['html' => '<p>secret</p>']);

$this->get(previewUrl($mail))
->assertRedirect(route('filament.admin.auth.login'));
});

it('forbids an authenticated user without mail permissions', function () {
$mail = Mail::factory()->create(['html' => '<p>secret</p>']);

MailsPlugin::get()->canManageMails(false);

$this->actingAs(mailUser())
->get(previewUrl($mail))
->assertForbidden();
});

it('allows a permitted user to preview a mail', function () {
$mail = Mail::factory()->create(['html' => '<p>secret</p>']);

MailsPlugin::get()->canManageMails(true);

$this->actingAs(mailUser())
->get(previewUrl($mail))
->assertOk()
->assertSee('secret');
});

it('redirects a guest away from an attachment download', function () {
Storage::fake('local');

$mail = Mail::factory()->create();
$attachment = attachmentFor($mail);

$this->get(downloadUrl($mail, $attachment))
->assertRedirect(route('filament.admin.auth.login'));
});

it('does not serve an attachment belonging to a different mail', function () {
Storage::fake('local');

$mail = Mail::factory()->create();
$otherMail = Mail::factory()->create();
$attachmentOfOtherMail = attachmentFor($otherMail, 'secret.pdf');

MailsPlugin::get()->canManageMails(true);

$this->actingAs(mailUser())
->get(downloadUrl($mail, $attachmentOfOtherMail))
->assertNotFound();
});

it('serves an attachment that belongs to the mail', function () {
Storage::fake('local');

$mail = Mail::factory()->create();
$attachment = attachmentFor($mail, 'invoice.pdf', 'INVOICE BODY');

MailsPlugin::get()->canManageMails(true);

$response = $this->actingAs(mailUser())
->get(downloadUrl($mail, $attachment))
->assertOk();

expect($response->streamedContent())->toBe('INVOICE BODY');
});

it('returns 404 for an unknown attachment', function () {
Storage::fake('local');

$mail = Mail::factory()->create();

MailsPlugin::get()->canManageMails(true);

$url = route('filament.admin.mails.attachment.download', [
'mail' => $mail->id,
'attachment' => 99999,
'filename' => 'nope.pdf',
]);

$this->actingAs(mailUser())->get($url)->assertNotFound();
});

it('returns 404 for an unknown mail preview', function () {
MailsPlugin::get()->canManageMails(true);

$url = route('filament.admin.mails.preview', ['mail' => 99999]);

$this->actingAs(mailUser())->get($url)->assertNotFound();
});

it('sends hardening headers with the preview', function () {
$mail = Mail::factory()->create(['html' => '<p>secret</p>']);

MailsPlugin::get()->canManageMails(true);

$this->actingAs(mailUser())
->get(previewUrl($mail))
->assertOk()
->assertHeader('X-Content-Type-Options', 'nosniff')
->assertHeader('Content-Security-Policy', "frame-ancestors 'self'");
});
Loading
Loading