From e46bba10919d97132928a85c0299fe70a49b992b Mon Sep 17 00:00:00 2001 From: Wessel Verheij Date: Fri, 21 Aug 2026 17:37:21 +0200 Subject: [PATCH] Add opt-in email digest for newly approved plugins Plugin approvals now announce in-app only (#476), so subscribers no longer get flooded with mail on every single approval. This adds back an explicit, admin-triggered way to email people about new plugins without returning to one-email-per-approval. A `new_plugin_notified_at` column on plugins tracks which approved plugins haven't been included in an email digest yet. A new `NewPluginsAvailable` notification (mail + database) covers one or many plugins in a single message. `SendPendingPluginEmailDigest` batches everything pending into one email per subscriber, excluding each recipient's own plugins from their own digest. An "Email Subscribers (N)" action on the admin Plugins list dispatches the job manually. `plugins:resend-new-plugin-notifications` is switched to the same batched notification so resending for named plugins also sends one combined email instead of one per plugin, keeping its existing signature, validation, and dry-run behaviour. Co-Authored-By: Claude Sonnet 5 --- .../Commands/ResendNewPluginNotifications.php | 12 +- .../PluginResource/Pages/ListPlugins.php | 23 +++ app/Jobs/SendPendingPluginEmailDigest.php | 42 ++++++ app/Models/Plugin.php | 14 ++ app/Notifications/NewPluginsAvailable.php | 73 ++++++++++ ...ew_plugin_notified_at_to_plugins_table.php | 22 +++ ...endingNewPluginNotificationsActionTest.php | 57 ++++++++ .../Jobs/SendPendingPluginEmailDigestTest.php | 131 ++++++++++++++++++ .../Notifications/NewPluginsAvailableTest.php | 113 +++++++++++++++ .../ResendNewPluginNotificationsTest.php | 47 ++++++- 10 files changed, 521 insertions(+), 13 deletions(-) create mode 100644 app/Jobs/SendPendingPluginEmailDigest.php create mode 100644 app/Notifications/NewPluginsAvailable.php create mode 100644 database/migrations/2026_08_21_120000_add_new_plugin_notified_at_to_plugins_table.php create mode 100644 tests/Feature/Filament/SendPendingNewPluginNotificationsActionTest.php create mode 100644 tests/Feature/Jobs/SendPendingPluginEmailDigestTest.php create mode 100644 tests/Feature/Notifications/NewPluginsAvailableTest.php diff --git a/app/Console/Commands/ResendNewPluginNotifications.php b/app/Console/Commands/ResendNewPluginNotifications.php index d87a063e..e1616805 100644 --- a/app/Console/Commands/ResendNewPluginNotifications.php +++ b/app/Console/Commands/ResendNewPluginNotifications.php @@ -4,7 +4,7 @@ use App\Models\Plugin; use App\Models\User; -use App\Notifications\NewPluginAvailable; +use App\Notifications\NewPluginsAvailable; use Illuminate\Console\Command; use Illuminate\Support\Facades\Notification; @@ -14,7 +14,7 @@ class ResendNewPluginNotifications extends Command {plugins* : Plugin names (vendor/package) to resend notifications for} {--dry-run : Preview what would happen without sending notifications}'; - protected $description = 'Resend NewPluginAvailable notifications to opted-in users for specified plugins'; + protected $description = 'Email opted-in users a single combined digest for the specified plugins'; public function handle(): int { @@ -57,13 +57,11 @@ public function handle(): int return Command::SUCCESS; } - foreach ($plugins as $plugin) { - $pluginRecipients = $recipients->where('id', '!=', $plugin->user_id); + Notification::send($recipients, new NewPluginsAvailable($plugins)); - Notification::send($pluginRecipients, new NewPluginAvailable($plugin)); + $plugins->each->update(['new_plugin_notified_at' => now()]); - $this->info("Sent NewPluginAvailable for {$plugin->name} to {$pluginRecipients->count()} users."); - } + $this->info("Sent a single digest covering {$plugins->count()} plugin(s) to {$recipients->count()} users."); $this->newLine(); $this->info('Done. All notifications queued.'); diff --git a/app/Filament/Resources/PluginResource/Pages/ListPlugins.php b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php index ba97676c..165240e6 100644 --- a/app/Filament/Resources/PluginResource/Pages/ListPlugins.php +++ b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php @@ -3,7 +3,10 @@ namespace App\Filament\Resources\PluginResource\Pages; use App\Filament\Resources\PluginResource; +use App\Jobs\SendPendingPluginEmailDigest; +use App\Models\Plugin; use Filament\Actions; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; class ListPlugins extends ListRecords @@ -13,6 +16,26 @@ class ListPlugins extends ListRecords protected function getHeaderActions(): array { return [ + Actions\Action::make('sendPendingNewPluginNotifications') + ->label(fn () => 'Email Subscribers ('.Plugin::query()->pendingNewPluginNotification()->count().')') + ->icon('heroicon-o-envelope') + ->color('warning') + ->visible(fn () => Plugin::query()->pendingNewPluginNotification()->count() > 0) + ->requiresConfirmation() + ->modalHeading('Email Pending Plugin Notifications') + ->modalDescription(fn () => 'This will email every subscribed user a single digest covering the ' + .Plugin::query()->pendingNewPluginNotification()->count() + .' plugin(s) approved since the last digest.') + ->action(function (): void { + SendPendingPluginEmailDigest::dispatch(); + + Notification::make() + ->title('Digest queued') + ->body('The digest email is being sent to subscribed users.') + ->success() + ->send(); + }), + Actions\CreateAction::make(), ]; } diff --git a/app/Jobs/SendPendingPluginEmailDigest.php b/app/Jobs/SendPendingPluginEmailDigest.php new file mode 100644 index 00000000..18198c30 --- /dev/null +++ b/app/Jobs/SendPendingPluginEmailDigest.php @@ -0,0 +1,42 @@ +pendingNewPluginNotification()->get(); + + if ($plugins->isEmpty()) { + return; + } + + $recipients = User::query() + ->whereNotNull('email_verified_at') + ->where('receives_new_plugin_notifications', true) + ->get(); + + foreach ($recipients as $recipient) { + $notifiablePlugins = $plugins->reject(fn (Plugin $plugin) => $plugin->user_id === $recipient->id); + + if ($notifiablePlugins->isEmpty()) { + continue; + } + + $recipient->notify(new NewPluginsAvailable($notifiablePlugins)); + } + + Plugin::query() + ->whereIn('id', $plugins->pluck('id')) + ->update(['new_plugin_notified_at' => now()]); + } +} diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index d81629b1..0b44b512 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -393,6 +393,19 @@ protected function featured(Builder $query): Builder return $query->where('featured', true); } + /** + * Approved plugins that have not yet been included in an emailed "new plugins" digest. + * + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function pendingNewPluginNotification(Builder $query): Builder + { + return $query->where('status', PluginStatus::Approved) + ->whereNull('new_plugin_notified_at'); + } + public function getPackagistUrl(): string { return "https://packagist.org/packages/{$this->name}"; @@ -814,6 +827,7 @@ protected function casts(): array 'type' => PluginType::class, 'tier' => PluginTier::class, 'approved_at' => 'datetime', + 'new_plugin_notified_at' => 'datetime', 'featured' => 'boolean', 'is_active' => 'boolean', 'is_official' => 'boolean', diff --git a/app/Notifications/NewPluginsAvailable.php b/app/Notifications/NewPluginsAvailable.php new file mode 100644 index 00000000..26ccd4ee --- /dev/null +++ b/app/Notifications/NewPluginsAvailable.php @@ -0,0 +1,73 @@ + $plugins + */ + public function __construct( + public Collection $plugins + ) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + if (! $notifiable->receives_new_plugin_notifications) { + return []; + } + + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + /** @var User $notifiable */ + $unsubscribeUrl = NotificationUnsubscribeController::signedUnsubscribeUrl($notifiable); + + $count = $this->plugins->count(); + + $mail = (new MailMessage) + ->subject($count === 1 + ? "New Plugin: {$this->plugins->first()->name}" + : "{$count} New Plugins on the NativePHP Marketplace") + ->greeting($count === 1 ? 'A new plugin is available!' : 'New plugins are available!') + ->line($count === 1 + ? 'The following plugin has just been added to the NativePHP Plugin Marketplace:' + : 'The following plugins have just been added to the NativePHP Plugin Marketplace:'); + + foreach ($this->plugins as $plugin) { + $mail->line('**['.$plugin->name.']('.route('plugins.show', $plugin->routeParams()).')**'); + } + + return $mail->line('[Unsubscribe from new plugin notifications]('.$unsubscribeUrl.').'); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'title' => $this->plugins->count() === 1 + ? "New Plugin: {$this->plugins->first()->name}" + : "{$this->plugins->count()} New Plugins on the NativePHP Marketplace", + 'plugin_ids' => $this->plugins->pluck('id')->all(), + 'plugin_names' => $this->plugins->pluck('name')->all(), + ]; + } +} diff --git a/database/migrations/2026_08_21_120000_add_new_plugin_notified_at_to_plugins_table.php b/database/migrations/2026_08_21_120000_add_new_plugin_notified_at_to_plugins_table.php new file mode 100644 index 00000000..72ce9d29 --- /dev/null +++ b/database/migrations/2026_08_21_120000_add_new_plugin_notified_at_to_plugins_table.php @@ -0,0 +1,22 @@ +timestamp('new_plugin_notified_at')->nullable()->after('approved_at'); + }); + } + + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('new_plugin_notified_at'); + }); + } +}; diff --git a/tests/Feature/Filament/SendPendingNewPluginNotificationsActionTest.php b/tests/Feature/Filament/SendPendingNewPluginNotificationsActionTest.php new file mode 100644 index 00000000..99ffdcdb --- /dev/null +++ b/tests/Feature/Filament/SendPendingNewPluginNotificationsActionTest.php @@ -0,0 +1,57 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_action_hidden_when_no_plugins_are_pending(): void + { + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->assertActionHidden('sendPendingNewPluginNotifications'); + } + + public function test_action_visible_when_plugins_are_pending(): void + { + Plugin::factory()->approved()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->assertActionVisible('sendPendingNewPluginNotifications'); + } + + public function test_action_dispatches_the_digest_job(): void + { + Bus::fake([SendPendingPluginEmailDigest::class]); + + Plugin::factory()->approved()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->callAction('sendPendingNewPluginNotifications') + ->assertNotified(); + + Bus::assertDispatched(SendPendingPluginEmailDigest::class); + } +} diff --git a/tests/Feature/Jobs/SendPendingPluginEmailDigestTest.php b/tests/Feature/Jobs/SendPendingPluginEmailDigestTest.php new file mode 100644 index 00000000..c0732e02 --- /dev/null +++ b/tests/Feature/Jobs/SendPendingPluginEmailDigestTest.php @@ -0,0 +1,131 @@ +create(); + $optedIn = User::factory()->create(['receives_new_plugin_notifications' => true]); + + $pluginOne = Plugin::factory()->approved()->for($author)->create(); + $pluginTwo = Plugin::factory()->approved()->for($author)->create(); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertSentTo($optedIn, NewPluginsAvailable::class, function ($notification) use ($pluginOne, $pluginTwo) { + return $notification->plugins->pluck('id')->sort()->values()->all() + === collect([$pluginOne->id, $pluginTwo->id])->sort()->values()->all(); + }); + } + + public function test_job_does_not_notify_opted_out_users(): void + { + Notification::fake(); + + $author = User::factory()->create(); + $optedOut = User::factory()->create(['receives_new_plugin_notifications' => false]); + Plugin::factory()->approved()->for($author)->create(); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertNotSentTo($optedOut, NewPluginsAvailable::class); + } + + public function test_job_does_not_notify_unverified_users(): void + { + Notification::fake(); + + $author = User::factory()->create(); + $unverified = User::factory()->unverified()->create(['receives_new_plugin_notifications' => true]); + Plugin::factory()->approved()->for($author)->create(); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertNotSentTo($unverified, NewPluginsAvailable::class); + } + + public function test_job_excludes_a_recipients_own_plugins_from_their_digest(): void + { + Notification::fake(); + + $author = User::factory()->create(['receives_new_plugin_notifications' => true]); + $otherAuthor = User::factory()->create(); + + $ownPlugin = Plugin::factory()->approved()->for($author)->create(); + $otherPlugin = Plugin::factory()->approved()->for($otherAuthor)->create(); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertSentTo($author, NewPluginsAvailable::class, function ($notification) use ($ownPlugin, $otherPlugin) { + return ! $notification->plugins->contains('id', $ownPlugin->id) + && $notification->plugins->contains('id', $otherPlugin->id); + }); + } + + public function test_job_skips_a_recipient_entirely_when_all_pending_plugins_are_their_own(): void + { + Notification::fake(); + + $author = User::factory()->create(['receives_new_plugin_notifications' => true]); + Plugin::factory()->approved()->for($author)->create(); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertNotSentTo($author, NewPluginsAvailable::class); + } + + public function test_job_marks_pending_plugins_as_notified(): void + { + Notification::fake(); + + $author = User::factory()->create(); + $plugin = Plugin::factory()->approved()->for($author)->create(); + + $this->assertNull($plugin->new_plugin_notified_at); + + (new SendPendingPluginEmailDigest)->handle(); + + $this->assertNotNull($plugin->fresh()->new_plugin_notified_at); + } + + public function test_job_ignores_plugins_already_notified(): void + { + Notification::fake(); + + $author = User::factory()->create(); + $recipient = User::factory()->create(['receives_new_plugin_notifications' => true]); + + Plugin::factory()->approved()->for($author)->create([ + 'new_plugin_notified_at' => now()->subDay(), + ]); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertNotSentTo($recipient, NewPluginsAvailable::class); + } + + public function test_job_does_nothing_when_no_plugins_are_pending(): void + { + Notification::fake(); + + User::factory()->create(['receives_new_plugin_notifications' => true]); + + (new SendPendingPluginEmailDigest)->handle(); + + Notification::assertNothingSent(); + } +} diff --git a/tests/Feature/Notifications/NewPluginsAvailableTest.php b/tests/Feature/Notifications/NewPluginsAvailableTest.php new file mode 100644 index 00000000..bd226677 --- /dev/null +++ b/tests/Feature/Notifications/NewPluginsAvailableTest.php @@ -0,0 +1,113 @@ +create(['receives_new_plugin_notifications' => false]); + $plugin = Plugin::factory()->create(); + + $notification = new NewPluginsAvailable(new Collection([$plugin])); + + $this->assertEmpty($notification->via($user)); + } + + public function test_via_returns_mail_and_database_when_user_opted_in(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => true]); + $plugin = Plugin::factory()->create(); + + $notification = new NewPluginsAvailable(new Collection([$plugin])); + + $this->assertEquals(['mail', 'database'], $notification->via($user)); + } + + public function test_mail_subject_names_the_single_plugin_when_only_one(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create(['name' => 'acme/awesome-plugin']); + + $notification = new NewPluginsAvailable(new Collection([$plugin])); + $mail = $notification->toMail($user); + + $this->assertEquals('New Plugin: acme/awesome-plugin', $mail->subject); + } + + public function test_mail_subject_uses_a_count_when_multiple_plugins(): void + { + $user = User::factory()->create(); + $plugins = Plugin::factory()->count(3)->create(); + + $notification = new NewPluginsAvailable($plugins); + $mail = $notification->toMail($user); + + $this->assertEquals('3 New Plugins on the NativePHP Marketplace', $mail->subject); + } + + public function test_mail_lists_every_plugin(): void + { + $user = User::factory()->create(); + $pluginOne = Plugin::factory()->create(['name' => 'acme/one']); + $pluginTwo = Plugin::factory()->create(['name' => 'acme/two']); + + $notification = new NewPluginsAvailable(new Collection([$pluginOne, $pluginTwo])); + $html = $notification->toMail($user)->render()->toHtml(); + + $this->assertStringContainsString('acme/one', $html); + $this->assertStringContainsString('acme/two', $html); + } + + public function test_mail_links_to_each_plugin_page(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create(['name' => 'acme/awesome-plugin']); + + $notification = new NewPluginsAvailable(new Collection([$plugin])); + $html = $notification->toMail($user)->render()->toHtml(); + + $this->assertStringContainsString( + route('plugins.show', ['vendor' => 'acme', 'package' => 'awesome-plugin']), + $html + ); + } + + public function test_database_notification_contains_all_plugin_ids(): void + { + $user = User::factory()->create(); + $pluginOne = Plugin::factory()->create(); + $pluginTwo = Plugin::factory()->create(); + + $notification = new NewPluginsAvailable(new Collection([$pluginOne, $pluginTwo])); + $data = $notification->toArray($user); + + $this->assertEquals([$pluginOne->id, $pluginTwo->id], $data['plugin_ids']); + } + + public function test_mail_contains_signed_unsubscribe_link(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create(); + + $notification = new NewPluginsAvailable(new Collection([$plugin])); + $mail = $notification->toMail($user); + + $baseUrl = route('notifications.unsubscribe', ['user' => $user]); + $found = collect($mail->introLines)->concat($mail->outroLines)->contains(function ($line) use ($baseUrl) { + return str_contains($line, 'Unsubscribe from new plugin notifications') + && str_contains($line, $baseUrl); + }); + + $this->assertTrue($found, 'Mail should contain a signed unsubscribe link.'); + } +} diff --git a/tests/Feature/ResendNewPluginNotificationsTest.php b/tests/Feature/ResendNewPluginNotificationsTest.php index b97171d9..d9791051 100644 --- a/tests/Feature/ResendNewPluginNotificationsTest.php +++ b/tests/Feature/ResendNewPluginNotificationsTest.php @@ -4,7 +4,7 @@ use App\Models\Plugin; use App\Models\User; -use App\Notifications\NewPluginAvailable; +use App\Notifications\NewPluginsAvailable; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Notification; use Tests\TestCase; @@ -27,8 +27,8 @@ public function test_sends_notifications_to_opted_in_users(): void 'plugins' => [$plugin->name], ])->assertSuccessful(); - Notification::assertSentTo($optedIn, NewPluginAvailable::class); - Notification::assertNotSentTo($optedOut, NewPluginAvailable::class); + Notification::assertSentTo($optedIn, NewPluginsAvailable::class); + Notification::assertNotSentTo($optedOut, NewPluginsAvailable::class); } public function test_does_not_send_to_plugin_author(): void @@ -42,7 +42,7 @@ public function test_does_not_send_to_plugin_author(): void 'plugins' => [$plugin->name], ])->assertSuccessful(); - Notification::assertNotSentTo($author, NewPluginAvailable::class); + Notification::assertNotSentTo($author, NewPluginsAvailable::class); } public function test_fails_when_plugin_not_found(): void @@ -76,7 +76,7 @@ public function test_dry_run_does_not_send_notifications(): void Notification::assertNothingSent(); } - public function test_handles_multiple_plugins(): void + public function test_handles_multiple_plugins_as_a_single_combined_digest(): void { Notification::fake(); @@ -88,7 +88,42 @@ public function test_handles_multiple_plugins(): void 'plugins' => [$plugin1->name, $plugin2->name], ])->assertSuccessful(); - Notification::assertSentTo($user, NewPluginAvailable::class, 2); + Notification::assertSentTo($user, NewPluginsAvailable::class, 1); + Notification::assertSentTo($user, NewPluginsAvailable::class, function ($notification) use ($plugin1, $plugin2) { + return $notification->plugins->pluck('id')->sort()->values()->all() + === collect([$plugin1->id, $plugin2->id])->sort()->values()->all(); + }); + } + + public function test_excludes_all_specified_plugin_authors_from_the_combined_digest(): void + { + Notification::fake(); + + $authorOne = User::factory()->create(['receives_new_plugin_notifications' => true]); + $authorTwo = User::factory()->create(['receives_new_plugin_notifications' => true]); + $plugin1 = Plugin::factory()->approved()->for($authorOne)->create(); + $plugin2 = Plugin::factory()->approved()->for($authorTwo)->create(); + + $this->artisan('plugins:resend-new-plugin-notifications', [ + 'plugins' => [$plugin1->name, $plugin2->name], + ])->assertSuccessful(); + + Notification::assertNotSentTo($authorOne, NewPluginsAvailable::class); + Notification::assertNotSentTo($authorTwo, NewPluginsAvailable::class); + } + + public function test_marks_sent_plugins_as_notified(): void + { + Notification::fake(); + + User::factory()->create(['receives_new_plugin_notifications' => true]); + $plugin = Plugin::factory()->approved()->create(); + + $this->artisan('plugins:resend-new-plugin-notifications', [ + 'plugins' => [$plugin->name], + ])->assertSuccessful(); + + $this->assertNotNull($plugin->fresh()->new_plugin_notified_at); } public function test_succeeds_with_no_opted_in_users(): void