diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 2c6185e..1361437 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -16,7 +16,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: '8.5' coverage: none - name: Install composer dependencies diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9b9e60b..fff09a2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -14,12 +14,10 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - php: [8.5, 8.4, 8.3] - laravel: ['13.*', '12.*', '11.*'] + php: [8.5, 8.4] + laravel: ['13.*', '12.*'] stability: [prefer-lowest, prefer-stable] include: - - laravel: 11.* - testbench: 9.17.0 - laravel: 12.* testbench: 10.* - laravel: 13.* diff --git a/.gitignore b/.gitignore index 4dc194b..05b0f86 100755 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ docs vendor coverage .phpunit.result.cache +.phpunit.cache .php_cs.cache .php-cs-fixer.cache diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results deleted file mode 100644 index 7a735be..0000000 --- a/.phpunit.cache/test-results +++ /dev/null @@ -1 +0,0 @@ -{"version":2,"defects":{"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribeApplicationServiceProviderTest::test_it_can_be_configured_to_loads_routes":5},"times":{"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_sends_mail_notifications_with_unsubscribe_links_for_mailing_lists":0.107,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_sends_mail_notifications_with_mailing_links_via_queues":0.008,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_sends_mail_notifications_with_an_unsubscribe_link_for_all_emails":0.003,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_sends_mail_notifications_normally_otherwise":0.003,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_handles_mailables_as_per_inherited_behavior":0.002,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_checks_if_a_notifiable_is_subscribed_to_receive_the_notification":0,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_does_not_send_mail_if_there_is_no_email_to_route_to":0,"YlsIdeas\\SubscribableNotifications\\Tests\\Channels\\SubscriberMailChannelTest::test_it_uses_views_set_on_the_mail_message_from_the_notification":0,"YlsIdeas\\SubscribableNotifications\\Tests\\Controllers\\UnsubscribeControllerTest::test_it_unsubscribes_users_from_all_mailing_lists":0.025,"YlsIdeas\\SubscribableNotifications\\Tests\\Controllers\\UnsubscribeControllerTest::test_it_unsubscribes_users_from_a_mailing_list":0.003,"YlsIdeas\\SubscribableNotifications\\Tests\\Controllers\\UnsubscribeControllerTest::test_it_uses_the_subscriber_to_redirect_the_user_after_completion":0.002,"YlsIdeas\\SubscribableNotifications\\Tests\\Controllers\\UnsubscribeControllerTest::test_it_aborts_if_the_target_model_does_not_exist":0.004,"YlsIdeas\\SubscribableNotifications\\Tests\\Controllers\\UnsubscribeControllerTest::test_it_fires_events_for_unsubscribing":0.003,"YlsIdeas\\SubscribableNotifications\\Tests\\Events\\UserUnsubscribedTest::test_it_can_be_initialised_with_parameters":0,"YlsIdeas\\SubscribableNotifications\\Tests\\Events\\UserUnsubscribingTest::test_it_can_be_initialised_with_parameters":0,"YlsIdeas\\SubscribableNotifications\\Tests\\MailSubscriberTest::test_it_generates_a_signed_url_for_users_to_unsubscribe":0.002,"YlsIdeas\\SubscribableNotifications\\Tests\\MailSubscriberTest::test_it_generates_a_signed_url_for_users_to_unsubscribe_from_a_mailing_list":0.002,"YlsIdeas\\SubscribableNotifications\\Tests\\MailSubscriberTest::test_it_can_check_its_subscription_status_for_all_mailing_lists":0.006,"YlsIdeas\\SubscribableNotifications\\Tests\\MailSubscriberTest::test_it_can_check_its_subscription_status_for_one_mailing_list":0.002,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribableServiceProviderTest::test_it_can_publish_views":0.008,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribableServiceProviderTest::test_it_loads_views":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribableServiceProviderTest::test_it_can_publish_an_application_service_provider":0.001,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribeApplicationServiceProviderTest::test_it_can_be_configured_to_loads_routes":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscribeApplicationServiceProviderTest::test_it_can_be_configured_to_not_loads_routes":0.001,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_unsubscribing_from_all_mailing_lists_via_closure":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_unsubscribing_from_all_mailing_lists_via_string":0.001,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_unsubscribing_from_a_mailing_list_via_closure":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_unsubscribing_from_a_mailing_list_via_string":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_generating_a_response_for_unsubscribing_via_closure":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_generating_a_response_for_unsubscribing_via_string":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_checking_subscription_status_of_a_mailing_list_via_closure":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_checking_subscription_status_of_a_mailing_list_via_string":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_checking_subscription_status_of_all_mailing_lists_via_closure":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_handles_checking_subscription_status_of_all_mailing_lists_via_string":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_can_provide_a_user_model":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_can_configure_a_user_model":0,"YlsIdeas\\SubscribableNotifications\\Tests\\SubscriberTest::test_it_can_configure_a_route_for_the_unsubscribe_controller":0}} \ No newline at end of file diff --git a/README.md b/README.md index 800e43e..a2bdcbe 100755 --- a/README.md +++ b/README.md @@ -6,321 +6,417 @@ [![Total Downloads](https://img.shields.io/packagist/dt/ylsideas/subscribable-notifications.svg?style=flat-square)](https://packagist.org/packages/ylsideas/subscribable-notifications) [![Laravel Compatibility](https://badge.laravel.cloud/badge/ylsideas/subscribable-notifications?style=flat)](https://packagist.org/packages/ylsideas/subscribable-notifications) -This package has been designed to help you handle email unsubscribes with as little as 5 minutes setup. After installing -your notifications sent over email should now be delivered with unsubscribe links in the footer and as a mail header -which email clients can present to the user for quicker unsubscribing. It can also handle resolving the unsubscribing -of the user through a signed route/controller. +Handle email unsubscribes with minimal setup. The package injects unsubscribe links into notification emails, provides a signed unsubscribe route and controller, and fully complies with [RFC 8058](https://www.rfc-editor.org/rfc/rfc8058) one-click unsubscribe — required by Gmail and Yahoo for bulk senders since 2024. -## Installation +Every email sent through the package will include both headers automatically: + +``` +List-Unsubscribe: +List-Unsubscribe-Post: List-Unsubscribe=One-Click +``` + +The unsubscribe route accepts `GET` (browser link) and `POST` (one-click from email clients), so users can unsubscribe without ever opening a browser. -You can install the package via composer: +## Requirements + +- PHP 8.4+ +- Laravel 12 or 13 + +## Installation ```bash -composer require ylsideas/subscribable-notifications +composer require ylsideas/subscribable-notifications:^2.0 ``` -Optionally to make use of the built in unsubscribing handler you can publish the application service -provider. If you wish to implement your own unsubscribing process and only insert unsubscribe links into -your notifications, you can forgo doing this. +Publish the application service provider: ```bash php artisan vendor:publish --tag=subscriber-provider ``` -This will create a `\App\Providers\SubscriberServiceProvider` class which you will need to register -in `config/app.php`. +This creates `App\Providers\SubscribableServiceProvider`. Register it in `bootstrap/providers.php` (Laravel 11+): + +```php +return [ + App\Providers\AppServiceProvider::class, + App\Providers\SubscribableServiceProvider::class, +]; +``` + +Or in `config/app.php` for older projects: ```php 'providers' => [ - ... - - /* - * Package Service Providers... - */ - \App\Providers\SubscribableServiceProvider::class, - - ... -] + // ... + App\Providers\SubscribableServiceProvider::class, +], ``` -After this you can configure your unsubscribe handlers quickly as methods within the service provider that return the closures. +The published provider is a plain `ServiceProvider` — open it and fill in the handler closures. There is no base class to extend or abstract methods to implement: -The package itself does not determine how you store or evaluate your users' subscribed state. Instead -it provides hooks in which to handle that. +```php +use Illuminate\Support\ServiceProvider; +use YlsIdeas\SubscribableNotifications\Facades\Subscriber; -## Usage +class SubscribableServiceProvider extends ServiceProvider +{ + public function boot(): void + { + Subscriber::routes(); -First off you must implement the `YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe` interface -on your notifiable User model. You can also apply the `YlsIdeas\SubscribableNotifications\MailSubscriber` trait -which will implement this for you to automatically provide signed urls for the unsubscribe controller provided -by this library. + Subscriber::onUnsubscribeFromMailingList(function ($notifiable, string $mailingList) { + // Remove the notifiable's subscription to the given mailing list. + }); -``` php -use YlsIdeas\SubscribableNotifications\MailSubscriber; -use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; + Subscriber::onUnsubscribeFromAllMailingLists(function ($notifiable) { + // Remove the notifiable's subscription to all mailing lists. + }); -class User implements CanUnsubscribe -{ - use Notifiable, MailSubscriber; + Subscriber::onCompletion(function ($notifiable, ?string $mailingList) { + return redirect('/'); + }); + + Subscriber::onCheckSubscriptionStatusOfMailingList(function ($notifiable, string $mailingList): bool { + return true; + }); + + Subscriber::onCheckSubscriptionStatusOfAllMailingLists(function ($notifiable): bool { + return true; + }); + } } ``` -### Implementing your own unsubscribe links +## Route configuration -If you wish to implement your own completely different `unsubscribeLink()` method you can. +### Throttling -``` php -use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; +The unsubscribe route is throttled to 60 requests per minute by default. Pass a custom rate or `false` to the `throttle` parameter to override this: + +```php +Subscriber::routes(); // default: 60 requests per minute +Subscriber::routes(throttle: '10,1'); // 10 requests per minute +Subscriber::routes(throttle: false); // disable throttling +``` + +The `throttle` parameter accepts the same `limit,decay` string format as Laravel's `throttle` middleware. -class User implements CanUnsubscribe +### CSRF + +Register the unsubscribe route **outside** the `web` middleware group. RFC 8058 one-click POST requests from email clients do not include a CSRF token, and wrapping the route in `web` will cause all POST unsubscribes to fail with a 419. + +The safest approach is to call `Subscriber::routes()` at the top of your service provider's `boot` method, before `Route::middleware('web')->group(...)`: + +```php +public function boot(): void { - use Notifiable; - - public function unsubscribeLink(?string $mailingList = ''): string - { - return URL::signedRoute( - 'sorry-to-see-you-go', - ['subscriber' => $this, 'mailingList' => $mailingList], - now()->addDays(1) - ); - } + Subscriber::routes(); + + // ... rest of your route/handler registration } ``` -### Implementing notifications as part of a mailing list +### Legacy route (v1 compatibility) -If you wish to apply specific mailing lists to notifications you need to implement the -`YlsIdeas\SubscribableNotifications\Contracts\AppliesToMailingList` on those notifications. -This will put two unsubscribe links into your emails generated from those notifications. -One for all emails and one for only that type of email. +If you are running a rolling upgrade from v1 and need old unsubscribe URLs (which do not include the subscriber type) to keep working, register the legacy route alongside the new one: -``` php -use YlsIdeas\SubscribableNotifications\Contracts\AppliesToMailingList; +```php +Subscriber::routes(); +Subscriber::legacyRoutes(\App\Models\User::class); +``` + +The legacy route matches `unsubscribe/{subscriberId}/{mailingList?}` and resolves the subscriber type to the given model (or its morph-map alias). All throttle options apply: + +```php +Subscriber::legacyRoutes(\App\Models\User::class, throttle: '10,1'); +Subscriber::legacyRoutes(\App\Models\User::class, throttle: false); +``` + +See [UPGRADE.md](UPGRADE.md) for the full migration guide. + +## Setup -class Welcome extends Notification implements AppliesToMailingList +### 1. Apply the trait to your notifiable model + +The `MailSubscriber` trait can be applied to **any Eloquent model** — not just `User`. The package uses Laravel's [morph map](https://laravel.com/docs/eloquent-relationships#custom-polymorphic-types) to identify models in signed URLs, so it works with multiple notifiable types side-by-side. + +```php +use YlsIdeas\SubscribableNotifications\MailSubscriber; +use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; +use YlsIdeas\SubscribableNotifications\Contracts\CheckSubscriptionStatusBeforeSendingNotifications; + +class User extends Authenticatable implements CanUnsubscribe, CheckSubscriptionStatusBeforeSendingNotifications { - ... - - public function usesMailingList(): string - { - return 'weekly-updates'; - } - - ... + use Notifiable, MailSubscriber; +} +``` + +You can apply it to any model that receives notifications: + +```php +class Contact extends Model implements CanUnsubscribe, CheckSubscriptionStatusBeforeSendingNotifications +{ + use Notifiable, MailSubscriber; } ``` -### Using the full unsubscribing workflow +### 2. Register a morph map (recommended) + +Without a morph map, the full class name appears in unsubscribe URLs. Registering aliases keeps URLs short and decouples them from your class names: + +```php +// In AppServiceProvider::boot() +use Illuminate\Database\Eloquent\Relations\Relation; + +Relation::morphMap([ + 'user' => \App\Models\User::class, + 'contact' => \App\Models\Contact::class, +]); +``` + +With this in place, a `User` unsubscribe URL looks like: + +``` +https://example.com/unsubscribe/user/42?signature=... +``` + +Without it, the full class name is used instead. -Using the `App\Providers\SubscriberServiceProvider` you can set up simple hooks to handle -unsubscribing the user from all future emails. This package doesn't determine how you should -store that record of opting out of future emails. Instead you provide functions in the provider -which will be called. The following are just examples of what you can do. +## Usage + +### Sending notifications with unsubscribe links + +No changes are needed to your notifications. Once the trait is on the model, every email notification sent to that model will automatically include unsubscribe links in the footer and the RFC 8058 headers. + +### Mailing list notifications + +Implement `AppliesToMailingList` on a notification to include a second, list-specific unsubscribe link alongside the global one. -#### Implementing an unsubscribe hook for a specific mailing list +You can use a plain string: -This handler will be called if a user links a link through to unsubscribe for a specific mailing list. +```php +use YlsIdeas\SubscribableNotifications\Contracts\AppliesToMailingList; -``` php -public class SubscriberServiceProvider +class WeeklyDigest extends Notification implements AppliesToMailingList { - ... - - public function onUnsubscribeFromMailingList() + public function usesMailingList(): string { - return function ($user, $mailingList) { - $user->mailing_lists = $user->mailing_lists->put($mailingList, false); - $user->save(); - }; + return 'weekly-digest'; } - - ... } ``` -#### Implementing an unsubscribe hook for all emails +Or a backed enum (recommended for type safety): -This handler will be called if the user has clicked through to the link to unsubscribe from all future emails. +```php +enum MailingList: string +{ + case WeeklyDigest = 'weekly-digest'; + case ProductUpdates = 'product-updates'; +} -``` php -public class SubscriberServiceProvider +class WeeklyDigest extends Notification implements AppliesToMailingList { - ... - - public function onUnsubscribeFromAllMailingLists() + public function usesMailingList(): string|\BackedEnum { - return function ($user) { - $user->unsubscribed_at = now(); - $user->save(); - }; + return MailingList::WeeklyDigest; } - - ... } ``` -#### Implementing an unsubscribe response +### Configuring the unsubscribe handlers -The completion handler will be called after a user is unsubscribed, allowing you to customise where the user is -redirected to or if you want to maybe show a further form even. +The five `Subscriber::on*` calls in the published provider are the only configuration needed. Each accepts a closure or a `Class@method` string that will be resolved from the service container: -``` php -public class SubscriberServiceProvider -{ - ... - - public function onCompletion() - { - return function ($user, $mailingList) { - return view('confirmation') - ->with('alert', 'You\'re not unsubscribed'); - }; - } - - ... -} +```php +Subscriber::onUnsubscribeFromAllMailingLists(\App\Handlers\UnsubscribeHandler::class . '@handleAll'); +``` + +A realistic implementation might look like: + +```php +Subscriber::onUnsubscribeFromMailingList(function ($notifiable, string $mailingList) { + $notifiable->subscriptions()->where('list', $mailingList)->delete(); +}); + +Subscriber::onUnsubscribeFromAllMailingLists(function ($notifiable) { + $notifiable->update(['unsubscribed_at' => now()]); +}); + +Subscriber::onCompletion(function ($notifiable, ?string $mailingList) { + return redirect()->route('unsubscribe.confirmed'); +}); + +Subscriber::onCheckSubscriptionStatusOfMailingList(function ($notifiable, string $mailingList): bool { + return $notifiable->subscriptions()->where('list', $mailingList)->exists(); +}); + +Subscriber::onCheckSubscriptionStatusOfAllMailingLists(function ($notifiable): bool { + return $notifiable->unsubscribed_at === null; +}); ``` -### Dedicated handler +### Blocking sends for unsubscribed users -You may also provide a string in the format of `class@method` that the subscriber class will use to grab the class -from the service container and then call the specified method on if you want to do something more custom. +To prevent notifications being sent to users who have opted out, implement `CheckNotifiableSubscriptionStatus` on the notification: ```php -public class SubscriberServiceProvider +use YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus; + +class WeeklyDigest extends Notification implements AppliesToMailingList, CheckNotifiableSubscriptionStatus { - ... - - public function onUnsubscribeFromAllMailingLists() + public function checkMailSubscriptionStatus(): bool { - return '\App\UnsubscribeHandler@handleUnsubscribing'; + return true; } - - ... } ``` -### Checking if a notification should be sent per the subscription - -You can also add hooks to check if a user should receive notifications for a mailing -list or for all mail notifications. +When this returns `true`, the channel checks `$notifiable->mailSubscriptionStatus($notification)` before sending. The `MailSubscriber` trait implements this automatically using your configured handlers. If both the mailing-list check and the all-mail check return `true`, the email sends; otherwise it is silently dropped. -To do this you need to make sure your user has the -`YlsIdeas\SubscribableNotifications\Contracts\CheckSubscriptionStatusBeforeSendingNotifications` interface -implemented. The `YlsIdeas\SubscribableNotifications\MailSubscriber` trait will implement this for you to use the -built in Subscriber handlers. +### Custom unsubscribe link -If you want to implement a method yourself to check the subscription you could also just implement the method yourself -like in the example below. +If you implement `CanUnsubscribe` directly instead of using the `MailSubscriber` trait, generate your own signed URL: -``` php +```php +use Illuminate\Support\Facades\URL; use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; -use YlsIdeas\SubscribableNotifications\Contracts\CheckSubscriptionStatusBeforeSendingNotifications; use YlsIdeas\SubscribableNotifications\Facades\Subscriber; -class User implements CanUnsubscribe, CheckSubscriptionStatusBeforeSendingNotifications +class User extends Authenticatable implements CanUnsubscribe { use Notifiable; - - - public function mailSubscriptionStatus(Notification $notification) : bool + + public function unsubscribeLink(?string $mailingList = null): string { - return Subscriber::checkSubscriptionStatus( - $this, - $notification instanceof AppliesToMailingList - ? $notification->usesMailingList() - : null + return URL::signedRoute( + Subscriber::routeName(), + [ + 'subscriberType' => $this->getMorphClass(), + 'subscriberId' => $this->getRouteKey(), + 'mailingList' => $mailingList, + ] ); } } ``` -Then you need to implement the -`YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus` interface on the notifications -that should trigger a check of the subscription status of the user it's being sent to. Then you just need to return -`true` if the subscription status should be checked. +### Customising the email templates -``` php -use YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus; +The default templates inject a small unsubscribe block into the footer of all notification emails. Publish them to customise: -class Welcome extends Notification implements CheckNotifiableSubscriptionStatus -{ - ... - - public function checkMailSubscriptionStatus() : bool - { - return true; - } - - ... -} +```bash +php artisan vendor:publish --tag=subscriber-views ``` -To use the functionality you then need to add your own Subscription check hooks. These hooks can be implemented -as you see fit. +This creates `resources/views/vendor/subscriber/html.blade.php` and `text.blade.php`. -``` php -public class SubscriberServiceProvider -{ - ... +## Testing - public function onCheckSubscriptionStatusOfMailingList() - { - return function ($user, $mailingList) { - return $user->mailing_lists->get($mailingList, false); - }; - } +### Scaffolding your tests - public function onCheckSubscriptionStatusOfAllMailingLists() - { - return function ($user) { - return $user->unsubscribed_at === null; - }; - } - - ... -} +Publish ready-to-customise test stubs into your application's `tests/Feature/` directory: + +```bash +php artisan vendor:publish --tag=subscriber-tests ``` -### Customising the email templates +This creates two files: -Out of the box the emails generated use the same templates except that they -inject a small bit of text into the footer of the emails. If you wish you customise -the templates further you may publish the views. +| File | What it covers | +|------|----------------| +| `tests/Feature/UnsubscribeRouteTest.php` | GET and POST unsubscribe routes, RFC 8058 body validation, tampered-URL rejection | +| `tests/Feature/SubscribableNotificationTest.php` | Subscription gating (subscribed sends, unsubscribed dropped), view data presence | -```bash -php artisan vendor:publish --tag=subscriber-views +Each file is annotated with `// TODO:` markers wherever you need to substitute your own model or notification class. The stubs use `Subscriber::fake()` so no real database handlers need to be configured. + +### Using the fake + +Use `Subscriber::fake()` in your tests to swap in a fake implementation and make assertions without needing real handlers configured: + +```php +use YlsIdeas\SubscribableNotifications\Facades\Subscriber; + +it('unsubscribes the user from the newsletter', function () { + $fake = Subscriber::fake(); + $user = User::factory()->create(); + + $this->get($user->unsubscribeLink('newsletter')); + + $fake->assertUnsubscribedFromMailingList($user, 'newsletter'); +}); + +it('unsubscribes the user from all emails', function () { + $fake = Subscriber::fake(); + $user = User::factory()->create(); + + $this->get($user->unsubscribeLink()); + + $fake->assertUnsubscribedFromAll($user); +}); ``` -This will create a `resources/views/vendor/subscriber` folder containing both `html.blade.php` -and `text.blade.php` which can be customised. These will then be the defaults used by the -notification mail channel. +Available assertions: -### Customising the User Model +| Method | Description | +|--------|-------------| +| `assertUnsubscribedFromMailingList($notifiable, $list)` | Assert the notifiable was unsubscribed from a specific list | +| `assertUnsubscribedFromAll($notifiable)` | Assert the notifiable was globally unsubscribed | +| `assertNothingUnsubscribed()` | Assert no unsubscribe actions occurred | +| `assertCheckedSubscriptionStatus($notifiable, $list)` | Assert subscription status was checked for the given list (`null` for global) | -If you are using a different User model than the one found in `app/Models/User.php` or -`app/Users.php` for Laravel 7 and earlier you can change this by calling. It's suggested you -do this in the boot method of the `SubscriberServiceProvider`. +To control subscription status checks in feature tests: ```php -Subscriber::userModel('App\Models\User'); +Subscriber::fake()->alwaysUnsubscribed(); // all checkSubscriptionStatus calls return false +Subscriber::fake()->alwaysSubscribed(); // all checkSubscriptionStatus calls return true (default) ``` -### Testing +### Testing subscription gating (`Mail::fake()` vs `Notification::fake()`) + +When testing whether an unsubscribed user is silently dropped, use `Mail::fake()` — **not** `Notification::fake()`. The subscription gate runs inside a `NotificationSending` event listener; `Notification::fake()` bypasses that event entirely, so the gate never fires and every notification appears to be sent regardless of subscription status. + +`Mail::fake()` intercepts at the transport layer, leaving the full notification pipeline — including the `NotificationSending` event — running normally: + +```php +use Illuminate\Support\Facades\Mail; + +it('does not send mail to unsubscribed users', function () { + Mail::fake(); + Subscriber::fake()->alwaysUnsubscribed(); + + $user = User::factory()->create(); + $user->notify(new WeeklyDigest()); -``` bash + Mail::assertNothingSent(); +}); + +it('sends mail to subscribed users', function () { + Mail::fake(); + Subscriber::fake()->alwaysSubscribed(); + + $user = User::factory()->create(); + $user->notify(new WeeklyDigest()); + + Mail::assertSentCount(1); +}); +``` + +## Running the test suite + +```bash composer test ``` -### Changelog +## Changelog -Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. ## Contributing Please see [CONTRIBUTING](CONTRIBUTING.md) for details. -### Security +## Security If you discover any security related issues, please email peter.fox@ylsideas.co instead of using the issue tracker. @@ -332,7 +428,3 @@ If you discover any security related issues, please email peter.fox@ylsideas.co ## License The MIT License (MIT). Please see [License File](LICENSE.md) for more information. - -## Laravel Package Boilerplate - -This package was generated using the [Laravel Package Boilerplate](https://laravelpackageboilerplate.com). diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..2252025 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,248 @@ +# Upgrade Guide + +## v1 → v2 + +### Overview of breaking changes + +| Area | v1 | v2 | +|---|---|---| +| PHP | 8.1+ | **8.4+** | +| Laravel | 9 / 10 / 11 | **12 / 13** | +| Service provider | Extend abstract `SubscribableApplicationServiceProvider` | Publish a plain stub and fill it in | +| Model binding | Single model class configured via `Subscriber::userModel()` | Polymorphic — any model works | +| Unsubscribe URL shape | `unsubscribe/{id}/{list?}` | `unsubscribe/{type}/{id}/{list?}` | +| Notification opt-in | Automatic for all `mail` channel notifications | Explicit — use `SubscribableMailMessage::via()` in `toMail()` | + +--- + +### Step 1 — Update composer.json + +```bash +composer require ylsideas/subscribable-notifications:^2.0 +``` + +--- + +### Step 2 — Replace SubscribableApplicationServiceProvider + +v1 required a class that extended the package's abstract `SubscribableApplicationServiceProvider` with five abstract methods. v2 replaces this with a plain stub you publish and configure. + +**Remove** your existing implementation (typically `App\Providers\SubscribableServiceProvider`): + +```php +// v1 — delete this +class SubscribableServiceProvider extends SubscribableApplicationServiceProvider +{ + protected $model = User::class; + + public function onUnsubscribeFromMailingList($user, $mailingList): void { ... } + public function onUnsubscribeFromAllMailingLists($user): void { ... } + public function onCompletion($user, ?string $mailingList): RedirectResponse { ... } + public function onCheckSubscriptionStatusForMailingLists($user, $mailingList): bool { ... } + public function onCheckSubscriptionStatusForAllMailingLists($user): bool { ... } +} +``` + +**Publish** the new stub: + +```bash +php artisan vendor:publish --tag=subscriber-provider +``` + +This creates `App\Providers\SubscribableServiceProvider`. Fill in the five callbacks — the logic you had in the abstract methods moves directly into `boot()`: + +```php +public function boot(): void +{ + Subscriber::routes(); + + Subscriber::onUnsubscribeFromMailingList(function ($notifiable, string $mailingList): void { + // e.g. $notifiable->subscriptions()->where('list', $mailingList)->delete(); + }); + + Subscriber::onUnsubscribeFromAllMailingLists(function ($notifiable): void { + // e.g. $notifiable->subscriptions()->delete(); + }); + + Subscriber::onCompletion(function ($notifiable, ?string $mailingList) { + return redirect('/'); + }); + + Subscriber::onCheckSubscriptionStatusOfMailingList(function ($notifiable, string $mailingList): bool { + return true; // e.g. $notifiable->isSubscribedTo($mailingList) + }); + + Subscriber::onCheckSubscriptionStatusOfAllMailingLists(function ($notifiable): bool { + return true; // e.g. ! $notifiable->hasUnsubscribedFromAll() + }); +} +``` + +Register the provider in `bootstrap/providers.php` if it isn't there already. + +--- + +### Step 3 — Update your subscriber model + +Remove any `Subscriber::userModel(User::class)` calls — this method no longer exists. The model is now resolved polymorphically from the URL. + +Your model must implement `CanUnsubscribe` and use the `MailSubscriber` trait. If it already does, no change is needed here; the trait now generates polymorphic URLs automatically. + +```php +use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; +use YlsIdeas\SubscribableNotifications\MailSubscriber; + +class User extends Authenticatable implements CanUnsubscribe +{ + use MailSubscriber; +} +``` + +**Add a morph map (strongly recommended).** Without one, the full class name (`App\Models\User`) appears in every unsubscribe URL. A morph map gives you a short, stable key instead: + +```php +// AppServiceProvider::boot() +use Illuminate\Database\Eloquent\Relations\Relation; + +Relation::morphMap([ + 'user' => \App\Models\User::class, +]); +``` + +With this in place, URLs contain `user` instead of the full class name, and refactoring or renaming your model won't invalidate existing links. + +--- + +### Step 4 — Update your notifications + +v1 automatically injected unsubscribe links into every `mail` channel notification by replacing the built-in `MailChannel`. v2 requires explicit opt-in — return `SubscribableMailMessage::via($notifiable, $this)` instead of a plain `MailMessage`. + +```php +// v1 +use Illuminate\Notifications\Messages\MailMessage; + +public function toMail(object $notifiable): MailMessage +{ + return (new MailMessage()) + ->subject('Your weekly digest') + ->line('Here is your digest...'); +} +``` + +```php +// v2 +use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage; + +public function toMail(object $notifiable): SubscribableMailMessage +{ + return SubscribableMailMessage::via($notifiable, $this) + ->subject('Your weekly digest') + ->line('Here is your digest...'); +} +``` + +`via()` sets the unsubscribe view data and registers the RFC 8058 `List-Unsubscribe` and `List-Unsubscribe-Post` headers via a `withSymfonyMessage()` callback. It also defaults the markdown template to `subscriber::html`; pass a third argument to override: + +```php +SubscribableMailMessage::via($notifiable, $this, 'my-theme::email') +``` + +If you want to apply the same behaviour to a custom `MailMessage` subclass, use the `SubscribableNotification` trait directly instead of extending `SubscribableMailMessage`: + +```php +use Illuminate\Notifications\Messages\MailMessage; +use YlsIdeas\SubscribableNotifications\Concerns\SubscribableNotification; + +class MyMailMessage extends MailMessage +{ + use SubscribableNotification; +} +``` + +--- + +### Step 5 — Handle the route URL change (critical for live systems) + +This is the most important production concern. Every unsubscribe link already delivered to a user's inbox points at the v1 URL shape: + +``` +# v1 +https://example.com/unsubscribe/123/newsletter?signature=... + +# v2 +https://example.com/unsubscribe/user/123/newsletter?signature=... +``` + +Those v1 links will stop working the moment you deploy v2 if nothing is done. + +#### Option A — Register the legacy compatibility route (recommended) + +Call `Subscriber::legacyRoutes()` alongside `Subscriber::routes()` in your service provider: + +```php +public function boot(): void +{ + Subscriber::routes(); + Subscriber::legacyRoutes(App\Models\User::class); // default model for old links + + // ... handlers +} +``` + +This registers a second route at the old URL shape (`unsubscribe/{id}/{list?}`) that forwards requests to the same controller, automatically resolving the model from the class you provide. The v1 signed URL signatures are over the URL path, which is unchanged, so they validate correctly. + +Keep `legacyRoutes()` active for as long as you consider old emails to still be in circulation. A safe window is 6–12 months, but you can remove it earlier once you are confident v1-style links have expired. + +#### Option B — Accept that old links break + +If your use case is transactional (receipts, one-time alerts) rather than recurring newsletters, old links may not matter. Remove the legacy route call and move on. + +--- + +### Step 6 — Optional: migrate mailing list identifiers to enums + +v1 mailing lists were plain strings. v2 supports PHP 8.1 backed enums via the `AppliesToMailingList` contract: + +```php +// Before +public function usesMailingList(): string +{ + return 'newsletter'; +} + +// After +enum MailingList: string +{ + case Newsletter = 'newsletter'; +} + +public function usesMailingList(): string|\BackedEnum +{ + return MailingList::Newsletter; +} +``` + +The enum's raw value (`'newsletter'`) is used in the URL, so existing links continue to work. + +--- + +### Step 7 — Optional: use Subscriber::fake() in tests + +v2 adds a test fake so you can assert unsubscribe behaviour without side effects: + +```php +$fake = Subscriber::fake(); + +// ... trigger unsubscribe ... + +$fake->assertUnsubscribedFromMailingList($user, 'newsletter'); +$fake->assertUnsubscribedFromAll($user); +$fake->assertNothingUnsubscribed(); +``` + +Control subscription status in tests: + +```php +$fake->alwaysSubscribed(); // all subscription checks return true +$fake->alwaysUnsubscribed(); // all subscription checks return false +``` diff --git a/composer.json b/composer.json index 337454e..6546955 100755 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "ylsideas/subscribable-notifications", - "description": "A Laravel package for adding unsubscribe links to notifications", + "description": "A Laravel package for adding RFC 8058 compliant unsubscribe links to notifications", "keywords": [ "ylsideas", "unsubscribable-notification", @@ -17,11 +17,11 @@ } ], "require": { - "php": "^8.3", - "illuminate/contracts": "13.*|12.*|11.*" + "php": "^8.4", + "illuminate/contracts": "13.*|12.*" }, "require-dev": { - "orchestra/testbench": "^9.17.0|^10.0|^11.0", + "orchestra/testbench": "^10.0|^11.0", "nunomaduro/collision": "^8.0", "larastan/larastan": "^2.0|^3.0", "pestphp/pest": "^2.34|^3.0|^4.0", @@ -59,7 +59,7 @@ "YlsIdeas\\SubscribableNotifications\\SubscribableServiceProvider" ], "aliases": { - "Subscriber": "YlsIdeas\\Facades\\Subscriber" + "Subscriber": "YlsIdeas\\SubscribableNotifications\\Facades\\Subscriber" } } }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a6f6a12..d30009a 100755 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,20 +1,10 @@ - - - - - - - - + tests - - - diff --git a/resources/views/html.blade.php b/resources/views/html.blade.php index aa5bc45..71c33e7 100644 --- a/resources/views/html.blade.php +++ b/resources/views/html.blade.php @@ -1,4 +1,4 @@ -@component('subscriber::mail.html.message') + {{-- Greeting --}} @if (! empty($greeting)) # {{ $greeting }} @@ -19,18 +19,14 @@ {{-- Action Button --}} @isset($actionText) $level, + default => 'primary', + }; ?> -@component('mail::button', ['url' => $actionUrl, 'color' => $color]) + {{ $actionText }} -@endcomponent + @endisset {{-- Outro Lines --}} @@ -43,36 +39,31 @@ @if (! empty($salutation)) {{ $salutation }} @else -@lang('Regards'),
{{ config('app.name') }} +@lang('Regards,')
+{{ config('app.name') }} +@endif + +{{-- Unsubscribe --}} +@if(($unsubscribeLink ?? false) || ($unsubscribeLinkForAll ?? false)) +--- +@if($unsubscribeLink ?? false) +@lang('If you no longer want to receive this type of email in the future use this [link](:link).', ['link' => $unsubscribeLink]) +@endif +@if($unsubscribeLinkForAll ?? false) +@lang('To no longer receive any future emails use this [link](:link).', ['link' => $unsubscribeLinkForAll]) +@endif @endif {{-- Subcopy --}} @isset($actionText) -@slot('subcopy') + @lang( - "If you’re having trouble clicking the \":actionText\" button, copy and paste the URL below\n". - 'into your web browser: [:actionURL](:actionURL).', + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n". + 'into your web browser:', [ 'actionText' => $actionText, - 'actionURL' => $actionUrl, ] -) -@endslot +) [{{ $displayableActionUrl }}]({{ $actionUrl }}) + @endisset - -@slot('unsubscribe') -@if($unsubscribeLink ?? false) -@lang( - 'If you no longer want to receive this type of email in the future use this [link](:link).', - ['link' => $unsubscribeLink] -) -@endif -@if($unsubscribeLinkForAll ?? false) -@lang( - 'To no longer receive any future emails use this [link](:link).', - ['link' => $unsubscribeLinkForAll] -) -@endif -@endslot - -@endcomponent +
diff --git a/resources/views/mail/html/message.blade.php b/resources/views/mail/html/message.blade.php deleted file mode 100644 index bbf14a4..0000000 --- a/resources/views/mail/html/message.blade.php +++ /dev/null @@ -1,31 +0,0 @@ -@component('mail::layout') - {{-- Header --}} - @slot('header') - @component('mail::header', ['url' => config('app.url')]) - {{ config('app.name') }} - @endcomponent - @endslot - - {{-- Body --}} - {{ $slot }} - - {{-- Subcopy --}} - @isset($subcopy) - @slot('subcopy') - @component('mail::subcopy') - {{ $subcopy }} - @endcomponent - @endslot - @endisset - - {{-- Footer --}} - @slot('footer') - @component('mail::footer') - © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.') - @isset($unsubscribe) -
- {{ $unsubscribe }} - @endisset - @endcomponent - @endslot -@endcomponent diff --git a/resources/views/mail/text/message.blade.php b/resources/views/mail/text/message.blade.php deleted file mode 100644 index 3100bc0..0000000 --- a/resources/views/mail/text/message.blade.php +++ /dev/null @@ -1,30 +0,0 @@ -@component('mail::layout') - {{-- Header --}} - @slot('header') - @component('mail::header', ['url' => config('app.url')]) - {{ config('app.name') }} - @endcomponent - @endslot - - {{-- Body --}} - {{ $slot }} - - {{-- Subcopy --}} - @isset($subcopy) - @slot('subcopy') - @component('mail::subcopy') - {{ $subcopy }} - @endcomponent - @endslot - @endisset - - {{-- Footer --}} - @slot('footer') - @component('mail::footer') - © {{ date('Y') }} {{ config('app.name') }}. @lang("All rights reserved.\n") -@isset($unsubscribe) -{{ $unsubscribe }} -@endisset - @endcomponent - @endslot -@endcomponent diff --git a/resources/views/text.blade.php b/resources/views/text.blade.php index c92e8cd..7374ec2 100644 --- a/resources/views/text.blade.php +++ b/resources/views/text.blade.php @@ -1,4 +1,4 @@ -@component('subscriber::mail.text.message') + {{-- Greeting --}} @if (! empty($greeting)) # {{ $greeting }} @@ -19,14 +19,10 @@ {{-- Action Button --}} @isset($actionText) $level, + default => 'primary', + }; ?> @component('mail::button', ['url' => $actionUrl, 'color' => $color]) {{ $actionText }} @@ -43,36 +39,28 @@ @if (! empty($salutation)) {{ $salutation }} @else -@lang('Regards'),
{{ config('app.name') }} +@lang('Regards,') +{{ config('app.name') }} +@endif + +{{-- Unsubscribe --}} +@if($unsubscribeLink ?? false) +@lang("If you no longer want to receive this type of email in the future go to :link.\n", ['link' => $unsubscribeLink]) +@endif +@if($unsubscribeLinkForAll ?? false) +@lang("To no longer receive any future emails go to :link.\n", ['link' => $unsubscribeLinkForAll]) @endif {{-- Subcopy --}} @isset($actionText) -@slot('subcopy') + @lang( - "If you’re having trouble clicking the \":actionText\" button, copy and paste the URL below\n". - 'into your web browser: [:actionURL](:actionURL).', + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n". + 'into your web browser:', [ 'actionText' => $actionText, - 'actionURL' => $actionUrl, ] -) -@endslot +) {{ $displayableActionUrl }} + @endisset - -@slot('unsubscribe') -@if($unsubscribeLink ?? false) -@lang( - "If you no longer want to receive this type of email in the future go to :link.\n", - ['link' => $unsubscribeLink] -) -@endif -@if($unsubscribeLinkForAll ?? false) -@lang( - "To no longer receive any future emails go to :link.\n", - ['link' => $unsubscribeLinkForAll] -) -@endif -@endslot - -@endcomponent +
diff --git a/src/Channels/SubscriberMailChannel.php b/src/Channels/SubscriberMailChannel.php deleted file mode 100644 index 3f4982e..0000000 --- a/src/Channels/SubscriberMailChannel.php +++ /dev/null @@ -1,117 +0,0 @@ -checkMailSubscriptionStatus() && - ! $notifiable->mailSubscriptionStatus($notification)) { - return null; - } - - if (method_exists($notification, 'toMail')) { - $message = $notification->toMail($notifiable); - } else { - throw new \RuntimeException('Notification does not support sending mail'); - } - - // Inject unsubscribe links for rendering in the view - if ($notifiable instanceof CanUnsubscribe && $message instanceof MailMessage) { - if ($notification instanceof AppliesToMailingList) { - $message->viewData['unsubscribeLink'] = $notifiable->unsubscribeLink( - $notification->usesMailingList() - ); - } - $message->viewData['unsubscribeLinkForAll'] = $notifiable->unsubscribeLink(); - } - - if (! $notifiable->routeNotificationFor('mail', $notification) && - ! $message instanceof Mailable) { - return null; - } - - if ($message instanceof Mailable) { - $message->send($this->mailer); - - return null; - } - - return $this->mailer->mailer($message->mailer ?? null)->send( - $this->buildView($message), - array_merge($message->data(), $this->additionalMessageData($notification)), - $this->messageBuilder($notifiable, $notification, $message) - ); - } - - /** - * Build the mail message. - * - * @param \Illuminate\Mail\Message $mailMessage - * @param mixed $notifiable - * @param \Illuminate\Notifications\Notification $notification - * @param \Illuminate\Notifications\Messages\MailMessage $message - * - * @return void - */ - protected function buildMessage($mailMessage, $notifiable, $notification, $message) - { - parent::buildMessage($mailMessage, $notifiable, $notification, $message); - - if ($notifiable instanceof CanUnsubscribe) { - $mailMessage->getHeaders()->addTextHeader( - 'List-Unsubscribe', - sprintf('<%s>', $notifiable->unsubscribeLink( - $notification instanceof AppliesToMailingList - ? $notification->usesMailingList() - : null - )) - ); - } - } - - /** - * Build the notification's view. - * - * @param \Illuminate\Notifications\Messages\MailMessage $message - * @return string|array - */ - protected function buildView($message) - { - if ($message->view) { - return $message->view; - } - - return [ - 'html' => $this->markdown->render('subscriber::html', $message->data()), - 'text' => $this->markdown->renderText('subscriber::text', $message->data()), - ]; - } -} diff --git a/src/Concerns/SubscribableNotification.php b/src/Concerns/SubscribableNotification.php new file mode 100644 index 0000000..05130df --- /dev/null +++ b/src/Concerns/SubscribableNotification.php @@ -0,0 +1,36 @@ +markdown = $template; + + $list = $notification instanceof AppliesToMailingList + ? $notification->usesMailingList() + : null; + $listValue = $list instanceof \BackedEnum ? $list->value : $list; + + $unsubscribeUrl = $notifiable->unsubscribeLink($listValue); + + if ($listValue !== null) { + $message->viewData['unsubscribeLink'] = $unsubscribeUrl; + } + $message->viewData['unsubscribeLinkForAll'] = $notifiable->unsubscribeLink(); + + return $message->withSymfonyMessage(function (Email $email) use ($unsubscribeUrl) { + $email->getHeaders()->addTextHeader('List-Unsubscribe', sprintf('<%s>', $unsubscribeUrl)); + $email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + }); + } +} diff --git a/src/Contracts/AppliesToMailingList.php b/src/Contracts/AppliesToMailingList.php index 023f77b..4ba6c3b 100644 --- a/src/Contracts/AppliesToMailingList.php +++ b/src/Contracts/AppliesToMailingList.php @@ -4,8 +4,5 @@ interface AppliesToMailingList { - /** - * @return string - */ - public function usesMailingList(): string; + public function usesMailingList(): string|\BackedEnum; } diff --git a/src/Contracts/SubscriberContract.php b/src/Contracts/SubscriberContract.php new file mode 100644 index 0000000..a435543 --- /dev/null +++ b/src/Contracts/SubscriberContract.php @@ -0,0 +1,32 @@ +middleware('signed'); + } + + public function __invoke(Request $request, mixed $subscriberId, ?string $mailingList = null): mixed + { + return app(UnsubscribeController::class)( + $request, + $this->subscriber->getLegacySubscriberType(), + $subscriberId, + $mailingList + ); + } +} diff --git a/src/Controllers/UnsubscribeController.php b/src/Controllers/UnsubscribeController.php index f4d5a15..245e65b 100644 --- a/src/Controllers/UnsubscribeController.php +++ b/src/Controllers/UnsubscribeController.php @@ -2,47 +2,33 @@ namespace YlsIdeas\SubscribableNotifications\Controllers; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; -use Illuminate\Http\Response; use Illuminate\Routing\Controller; +use YlsIdeas\SubscribableNotifications\Contracts\CanUnsubscribe; +use YlsIdeas\SubscribableNotifications\Contracts\SubscriberContract; use YlsIdeas\SubscribableNotifications\Events\UserUnsubscribed; use YlsIdeas\SubscribableNotifications\Events\UserUnsubscribing; -use YlsIdeas\SubscribableNotifications\Subscriber; -/** - * Class UnsubscribeController. - */ -class UnsubscribeController extends Controller +final class UnsubscribeController extends Controller { - /** - * @var Subscriber - */ - protected $subscriber; - - /** - * UnsubscribeController constructor. - * @param Subscriber $subscriber - */ - public function __construct(Subscriber $subscriber) + public function __construct(private readonly SubscriberContract $subscriber) { $this->middleware('signed'); - $this->subscriber = $subscriber; } - /** - * Handle the incoming request. - * - * @param Request $request - * @param mixed $subscriber - * @param string|null $mailingList - * @return Response - */ - public function __invoke(Request $request, $subscriber, ?string $mailingList = null) + public function __invoke(Request $request, string $subscriberType, mixed $subscriberId, ?string $mailingList = null): mixed { - $model = new $this->subscriber->userModel(); + $modelClass = Relation::getMorphedModel($subscriberType) ?? $subscriberType; + + if (! class_exists($modelClass) || ! is_a($modelClass, CanUnsubscribe::class, true)) { + abort(403, __('Could not process unsubscribe request')); + } + + $model = new $modelClass(); $subscriber = $model - ->where($model->getRouteKeyName(), $subscriber) + ->where($model->getRouteKeyName(), $subscriberId) ->first(); if (! $subscriber) { @@ -59,6 +45,14 @@ public function __invoke(Request $request, $subscriber, ?string $mailingList = n event(new UserUnsubscribed($subscriber, $mailingList)); + if ($request->isMethod('post')) { + if ($request->input('List-Unsubscribe') !== 'One-Click') { + abort(400, __('Invalid unsubscribe request')); + } + + return response('', 204); + } + return $this->subscriber->complete($subscriber, $mailingList); } } diff --git a/src/Events/UserUnsubscribed.php b/src/Events/UserUnsubscribed.php index 2b20a09..d1d428b 100644 --- a/src/Events/UserUnsubscribed.php +++ b/src/Events/UserUnsubscribed.php @@ -2,22 +2,11 @@ namespace YlsIdeas\SubscribableNotifications\Events; -use Illuminate\Foundation\Auth\User; - -class UserUnsubscribed +final class UserUnsubscribed { - /** - * @var User - */ - public $user; - /** - * @var string|null - */ - public $mailingList; - - public function __construct($user, ?string $mailingList = null) - { - $this->user = $user; - $this->mailingList = $mailingList; + public function __construct( + public readonly object $user, + public readonly ?string $mailingList = null, + ) { } } diff --git a/src/Events/UserUnsubscribing.php b/src/Events/UserUnsubscribing.php index 9c82ddf..1b7525a 100644 --- a/src/Events/UserUnsubscribing.php +++ b/src/Events/UserUnsubscribing.php @@ -2,22 +2,11 @@ namespace YlsIdeas\SubscribableNotifications\Events; -use Illuminate\Foundation\Auth\User; - -class UserUnsubscribing +final class UserUnsubscribing { - /** - * @var User - */ - public $user; - /** - * @var string|null - */ - public $mailingList; - - public function __construct($user, ?string $mailingList = null) - { - $this->user = $user; - $this->mailingList = $mailingList; + public function __construct( + public readonly object $user, + public readonly ?string $mailingList = null, + ) { } } diff --git a/src/Facades/Subscriber.php b/src/Facades/Subscriber.php index 93776a8..2fff80a 100644 --- a/src/Facades/Subscriber.php +++ b/src/Facades/Subscriber.php @@ -4,15 +4,17 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\Facade; +use YlsIdeas\SubscribableNotifications\Contracts\SubscriberContract; +use YlsIdeas\SubscribableNotifications\Testing\FakeSubscriber; /** * Class Subscriber. * * @see \YlsIdeas\SubscribableNotifications\Subscriber * - * @method static void routes() + * @method static void routes(mixed $router = null, string|false $throttle = '60,1') + * @method static void legacyRoutes(string $defaultModel, mixed $router = null, string|false $throttle = '60,1') * @method static string routeName() - * @method static mixed userModel(string $model = null) * @method static void onCompletion(callable|string $handler) * @method static void onUnsubscribeFromMailingList(callable|string $handler) * @method static void onUnsubscribeFromAllMailingLists(callable|string $handler) @@ -25,8 +27,23 @@ */ class Subscriber extends Facade { - protected static function getFacadeAccessor() + protected static function getFacadeAccessor(): string { return \YlsIdeas\SubscribableNotifications\Subscriber::class; } + + public static function fake(): FakeSubscriber + { + $fake = new FakeSubscriber(); + + $app = static::getFacadeApplication(); + $app->forgetInstance(\YlsIdeas\SubscribableNotifications\Subscriber::class); + $app->forgetInstance(SubscriberContract::class); + $app->instance(\YlsIdeas\SubscribableNotifications\Subscriber::class, $fake); + $app->instance(SubscriberContract::class, $fake); + + static::swap($fake); + + return $fake; + } } diff --git a/src/MailSubscriber.php b/src/MailSubscriber.php index b3384f4..4decab5 100644 --- a/src/MailSubscriber.php +++ b/src/MailSubscriber.php @@ -9,29 +9,27 @@ trait MailSubscriber { - /** - * @param string|null $mailingList - * @return string - */ - public function unsubscribeLink(?string $mailingList = ''): string + public function unsubscribeLink(?string $mailingList = null): string { return URL::signedRoute( Subscriber::routeName(), - ['subscriber' => $this, 'mailingList' => $mailingList] + [ + 'subscriberType' => $this->getMorphClass(), + 'subscriberId' => $this->getRouteKey(), + 'mailingList' => $mailingList, + ] ); } - /** - * @param Notification $notification - * @return bool - */ public function mailSubscriptionStatus(Notification $notification): bool { + $list = $notification instanceof AppliesToMailingList + ? $notification->usesMailingList() + : null; + return Subscriber::checkSubscriptionStatus( $this, - $notification instanceof AppliesToMailingList - ? $notification->usesMailingList() - : null + $list instanceof \BackedEnum ? $list->value : $list ); } } diff --git a/src/Messages/SubscribableMailMessage.php b/src/Messages/SubscribableMailMessage.php new file mode 100644 index 0000000..d4e380d --- /dev/null +++ b/src/Messages/SubscribableMailMessage.php @@ -0,0 +1,11 @@ +loadRoutes === true) { - $this->loadRoutes(); - } - - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::userModel( - $this->userModel() - ); - - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::onUnsubscribeFromMailingList( - $this->onUnsubscribeFromMailingList() - ); - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::onUnsubscribeFromAllMailingLists( - $this->onUnsubscribeFromAllMailingLists() - ); - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::onCompletion( - $this->onCompletion() - ); - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::onCheckSubscriptionStatusOfMailingList( - $this->onCheckSubscriptionStatusOfMailingList() - ); - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::onCheckSubscriptionStatusOfAllMailingLists( - $this->onCheckSubscriptionStatusOfAllMailingLists() - ); - } - - protected function userModel() - { - if ($this->model != null) { - return $this->model; - } - - if (version_compare($this->app->version(), '8.0.0', '>=')) { - return '\App\Models\User'; - } - - return '\App\User'; - } - - public function loadRoutes() - { - \YlsIdeas\SubscribableNotifications\Facades\Subscriber::routes(); - } - - /** - * @return callable|string - */ - abstract public function onUnsubscribeFromMailingList(); - - /** - * @return callable|string - */ - abstract public function onUnsubscribeFromAllMailingLists(); - - /** - * @return callable|string - */ - abstract public function onCompletion(); - - /** - * @return callable|string - */ - abstract public function onCheckSubscriptionStatusOfMailingList(); - - /** - * @return callable|string - */ - abstract public function onCheckSubscriptionStatusOfAllMailingLists(); -} diff --git a/src/SubscribableServiceProvider.php b/src/SubscribableServiceProvider.php index cacae53..8d9d7ab 100755 --- a/src/SubscribableServiceProvider.php +++ b/src/SubscribableServiceProvider.php @@ -2,17 +2,16 @@ namespace YlsIdeas\SubscribableNotifications; -use Illuminate\Contracts\Foundation\Application; -use Illuminate\Notifications\Channels\MailChannel; +use Illuminate\Notifications\Events\NotificationSending; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; -use YlsIdeas\SubscribableNotifications\Channels\SubscriberMailChannel; +use YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus; +use YlsIdeas\SubscribableNotifications\Contracts\CheckSubscriptionStatusBeforeSendingNotifications; +use YlsIdeas\SubscribableNotifications\Contracts\SubscriberContract; -class SubscribableServiceProvider extends ServiceProvider +final class SubscribableServiceProvider extends ServiceProvider { - /** - * Bootstrap the application services. - */ - public function boot() + public function boot(): void { $this->loadViewsFrom(__DIR__.'/../resources/views', 'subscriber'); @@ -24,18 +23,29 @@ public function boot() $this->publishes([ __DIR__.'/../stubs/SubscribableServiceProvider.stub' => app_path('Providers/SubscribableServiceProvider.php'), ], 'subscriber-provider'); + + $this->publishes([ + __DIR__.'/../stubs/tests/UnsubscribeRouteTest.stub' => base_path('tests/Feature/UnsubscribeRouteTest.php'), + __DIR__.'/../stubs/tests/SubscribableNotificationTest.stub' => base_path('tests/Feature/SubscribableNotificationTest.php'), + ], 'subscriber-tests'); } + + Event::listen(NotificationSending::class, function (NotificationSending $event) { + if ($event->channel !== 'mail') { + return; + } + if ($event->notifiable instanceof CheckSubscriptionStatusBeforeSendingNotifications && + $event->notification instanceof CheckNotifiableSubscriptionStatus && + $event->notification->checkMailSubscriptionStatus() && + ! $event->notifiable->mailSubscriptionStatus($event->notification)) { + return false; + } + }); } - /** - * Register the application services. - */ - public function register() + public function register(): void { - $this->app->bind(MailChannel::class, SubscriberMailChannel::class); - $this->app->singleton(Subscriber::class, function (Application $app) { - /** @phpstan-ignore-next-line */ - return new Subscriber($app); - }); + $this->app->singleton(Subscriber::class, fn () => new Subscriber($this->app)); + $this->app->alias(Subscriber::class, SubscriberContract::class); } } diff --git a/src/Subscriber.php b/src/Subscriber.php index 8da2293..76cd463 100644 --- a/src/Subscriber.php +++ b/src/Subscriber.php @@ -3,187 +3,145 @@ namespace YlsIdeas\SubscribableNotifications; use Illuminate\Contracts\Foundation\Application; -use Illuminate\Http\Response; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Str; +use YlsIdeas\SubscribableNotifications\Contracts\SubscriberContract; -class Subscriber +final class Subscriber implements SubscriberContract { - /** - * @var string - */ - public $uri = 'unsubscribe/{subscriber}/{mailingList?}'; - /** - * @var string - */ - public $hander = '\YlsIdeas\SubscribableNotifications\Controllers\UnsubscribeController'; - /** - * @var string - */ - public $routeName = 'unsubscribe'; - /** - * @var string - */ - public $userModel = '\App\Models\User'; - /** - * @var callable - */ - protected $onUnsubscribeFromMailingList; - /** - * @var callable - */ - protected $onUnsubscribeFromAllMailingLists; - /** - * @var callable - */ - protected $onCompletion; - /** - * @var callable - */ - protected $onCheckSubscriptionStatusForMailingLists; - /** - * @var callable - */ - protected $onCheckSubscriptionStatusForAllMailingLists; - - /** - * @var Application - */ - protected $app; - - public function __construct(Application $app) + public string $uri = 'unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}'; + public string $handler = '\YlsIdeas\SubscribableNotifications\Controllers\UnsubscribeController'; + public string $routeName = 'unsubscribe'; + + /** Morph type stored when legacyRoutes() is called — used by LegacyUnsubscribeController. */ + private ?string $legacySubscriberType = null; + + public function getLegacySubscriberType(): ?string { - $this->app = $app; + return $this->legacySubscriberType; } - public function routes($router = null) + private ?\Closure $onUnsubscribeFromMailingList = null; + private ?\Closure $onUnsubscribeFromAllMailingLists = null; + private ?\Closure $onCompletion = null; + private ?\Closure $onCheckSubscriptionStatusForMailingLists = null; + private ?\Closure $onCheckSubscriptionStatusForAllMailingLists = null; + + public function __construct(private readonly Application $app) { - $router = $router ?? $this->app->make('router'); - $router->get( - $this->uri, - $this->hander - ) - ->name($this->routeName); } - /** - * @return string - */ - public function routeName() + public function routes(mixed $router = null, string|false $throttle = '60,1'): void { - return $this->routeName; + $router = $router ?? $this->app->make('router'); + $route = $router->match(['GET', 'POST'], $this->uri, $this->handler) + ->name($this->routeName) + ->where('subscriberType', '[^\d/][^/]*'); + + if ($throttle !== false) { + $route->middleware("throttle:{$throttle}"); + } } - /** - * @param string|null $model - * @return string|null - */ - public function userModel(?string $model = null) + public function legacyRoutes(string $defaultModel, mixed $router = null, string|false $throttle = '60,1'): void { - if ($model) { - $this->userModel = $model; + $router = $router ?? $this->app->make('router'); + + $morphMap = Relation::morphMap(); + $this->legacySubscriberType = array_search($defaultModel, $morphMap, true) ?: $defaultModel; + + $route = $router->match( + ['GET', 'POST'], + 'unsubscribe/{subscriberId}/{mailingList?}', + '\YlsIdeas\SubscribableNotifications\Controllers\LegacyUnsubscribeController' + )->name($this->routeName . '.legacy'); - return null; + if ($throttle !== false) { + $route->middleware("throttle:{$throttle}"); } + } - return $this->userModel; + public function routeName(): string + { + return $this->routeName; } - /** - * @param string|callable $handler - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function onUnsubscribeFromMailingList($handler) + public function onUnsubscribeFromMailingList(string|callable $handler): void { $this->onUnsubscribeFromMailingList = $this->parseHandler($handler); } - /** - * @param string|callable $handler - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function onUnsubscribeFromAllMailingLists($handler) + public function onUnsubscribeFromAllMailingLists(string|callable $handler): void { $this->onUnsubscribeFromAllMailingLists = $this->parseHandler($handler); } - /** - * @param string|callable $handler - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function onCompletion($handler) + public function onCompletion(string|callable $handler): void { $this->onCompletion = $this->parseHandler($handler); } - /** - * @param string|callable $handler - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function onCheckSubscriptionStatusOfAllMailingLists($handler) + public function onCheckSubscriptionStatusOfAllMailingLists(string|callable $handler): void { $this->onCheckSubscriptionStatusForAllMailingLists = $this->parseHandler($handler); } - /** - * @param string|callable $handler - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function onCheckSubscriptionStatusOfMailingList($handler) + public function onCheckSubscriptionStatusOfMailingList(string|callable $handler): void { $this->onCheckSubscriptionStatusForMailingLists = $this->parseHandler($handler); } - /** - * @param mixed $user - * @param string $mailingList - */ - public function unsubscribeFromMailingList($user, string $mailingList) + public function unsubscribeFromMailingList(mixed $user, string $mailingList): void { - call_user_func($this->onUnsubscribeFromMailingList, $user, $mailingList); + if ($this->onUnsubscribeFromMailingList === null) { + throw new \LogicException('No handler registered for mailing-list unsubscribes. Call Subscriber::onUnsubscribeFromMailingList() in your service provider.'); + } + ($this->onUnsubscribeFromMailingList)($user, $mailingList); } - /** - * @param mixed $user - */ - public function unsubscribeFromAllMailingLists($user) + public function unsubscribeFromAllMailingLists(mixed $user): void { - call_user_func($this->onUnsubscribeFromAllMailingLists, $user); + if ($this->onUnsubscribeFromAllMailingLists === null) { + throw new \LogicException('No handler registered for global unsubscribes. Call Subscriber::onUnsubscribeFromAllMailingLists() in your service provider.'); + } + ($this->onUnsubscribeFromAllMailingLists)($user); } - /** - * @param mixed $user - * @param string|null $mailingList - * @return Response - */ - public function complete($user, ?string $mailingList = null) + public function complete(mixed $user, ?string $mailingList = null): mixed { - return call_user_func($this->onCompletion, $user, $mailingList); + if ($this->onCompletion === null) { + throw new \LogicException('No completion handler registered. Call Subscriber::onCompletion() in your service provider.'); + } + + return ($this->onCompletion)($user, $mailingList); } - /** - * @param mixed $user - * @param string|null $mailingList - * @return bool - */ - public function checkSubscriptionStatus($user, ?string $mailingList = null) + public function checkSubscriptionStatus(mixed $user, ?string $mailingList = null): bool { + if ($this->onCheckSubscriptionStatusForAllMailingLists === null) { + return true; + } + if ($mailingList !== null) { - return (bool) call_user_func($this->onCheckSubscriptionStatusForAllMailingLists, $user) && - (bool) call_user_func($this->onCheckSubscriptionStatusForMailingLists, $user, $mailingList); + if ($this->onCheckSubscriptionStatusForMailingLists === null) { + return (bool) ($this->onCheckSubscriptionStatusForAllMailingLists)($user); + } + + return (bool) ($this->onCheckSubscriptionStatusForAllMailingLists)($user) + && (bool) ($this->onCheckSubscriptionStatusForMailingLists)($user, $mailingList); } - return (bool) call_user_func($this->onCheckSubscriptionStatusForAllMailingLists, $user); + return (bool) ($this->onCheckSubscriptionStatusForAllMailingLists)($user); } - protected function parseHandler(string|callable$handler): callable + private function parseHandler(string|callable $handler): \Closure { if (is_string($handler)) { - $parsed = Str::parseCallback($handler); - $parsed[0] = $this->app->make($parsed[0]); + [$class, $method] = Str::parseCallback($handler, '__invoke'); - return $parsed; + return \Closure::fromCallable([$this->app->make($class), $method]); } - return $handler; + return \Closure::fromCallable($handler); } } diff --git a/src/Testing/FakeSubscriber.php b/src/Testing/FakeSubscriber.php new file mode 100644 index 0000000..b3428f1 --- /dev/null +++ b/src/Testing/FakeSubscriber.php @@ -0,0 +1,161 @@ +routeName; + } + + public function onUnsubscribeFromMailingList(mixed $handler): void + { + } + + public function onUnsubscribeFromAllMailingLists(mixed $handler): void + { + } + + public function onCompletion(mixed $handler): void + { + } + + public function onCheckSubscriptionStatusOfAllMailingLists(mixed $handler): void + { + } + + public function onCheckSubscriptionStatusOfMailingList(mixed $handler): void + { + } + + public function unsubscribeFromMailingList(mixed $user, string $mailingList): void + { + $this->unsubscribedFromMailingList[] = ['user' => $user, 'list' => $mailingList]; + } + + public function unsubscribeFromAllMailingLists(mixed $user): void + { + $this->unsubscribedFromAll[] = $user; + } + + public function complete(mixed $user, ?string $mailingList = null): Response + { + return new Response('', 200); + } + + public function checkSubscriptionStatus(mixed $user, ?string $mailingList = null): bool + { + $this->subscriptionStatusChecks[] = ['user' => $user, 'mailingList' => $mailingList]; + + return $this->subscriptionStatus; + } + + public function alwaysSubscribed(): static + { + $this->subscriptionStatus = true; + + return $this; + } + + public function alwaysUnsubscribed(): static + { + $this->subscriptionStatus = false; + + return $this; + } + + public function assertUnsubscribedFromMailingList(mixed $user, string $mailingList): void + { + Assert::assertTrue( + collect($this->unsubscribedFromMailingList) + ->contains(fn ($item) => $this->matches($item['user'], $user) && $item['list'] === $mailingList), + "Failed asserting that the user was unsubscribed from [{$mailingList}]." + ); + } + + public function assertUnsubscribedFromAll(mixed $user): void + { + Assert::assertTrue( + collect($this->unsubscribedFromAll)->contains(fn ($item) => $this->matches($item, $user)), + 'Failed asserting that the user was unsubscribed from all mailing lists.' + ); + } + + public function assertCheckedSubscriptionStatus(mixed $user, ?string $mailingList): void + { + Assert::assertTrue( + collect($this->subscriptionStatusChecks) + ->contains(fn ($item) => $this->matches($item['user'], $user) && $item['mailingList'] === $mailingList), + $mailingList !== null + ? "Failed asserting that subscription status was checked for mailing list [{$mailingList}]." + : 'Failed asserting that subscription status was checked for all mailing lists.' + ); + } + + public function assertNothingUnsubscribed(): void + { + Assert::assertEmpty( + $this->unsubscribedFromMailingList, + 'Failed asserting that no mailing list unsubscribes occurred.' + ); + Assert::assertEmpty( + $this->unsubscribedFromAll, + 'Failed asserting that no all-mail unsubscribes occurred.' + ); + } + + public function getUnsubscribedFromMailingList(): array + { + return $this->unsubscribedFromMailingList; + } + + public function getUnsubscribedFromAll(): array + { + return $this->unsubscribedFromAll; + } + + public function getSubscriptionStatusChecks(): array + { + return $this->subscriptionStatusChecks; + } + + protected function matches(mixed $expected, mixed $actual): bool + { + if ($expected === $actual) { + return true; + } + + if ($expected instanceof \Illuminate\Database\Eloquent\Model && + $actual instanceof \Illuminate\Database\Eloquent\Model) { + return $expected->getMorphClass() === $actual->getMorphClass() + && (string) $expected->getKey() === (string) $actual->getKey(); + } + + return false; + } +} diff --git a/stubs/SubscribableServiceProvider.stub b/stubs/SubscribableServiceProvider.stub index 7baeb38..3a6555a 100644 --- a/stubs/SubscribableServiceProvider.stub +++ b/stubs/SubscribableServiceProvider.stub @@ -2,63 +2,41 @@ namespace App\Providers; -use YlsIdeas\SubscribableNotifications\SubscribableApplicationServiceProvider; +use Illuminate\Support\ServiceProvider; +use YlsIdeas\SubscribableNotifications\Facades\Subscriber; -class SubscribableServiceProvider extends SubscribableApplicationServiceProvider +class SubscribableServiceProvider extends ServiceProvider { - /** - * @var bool - */ - protected $loadRoutes = true; - - /** - * @return callable|string - */ - public function onUnsubscribeFromMailingList() - { - return function ($user, $mailingList) { - - }; - } - - /** - * @return callable|string - */ - public function onUnsubscribeFromAllMailingLists() - { - return function ($user) { - - }; - } - - /** - * @return callable|string - */ - public function onCompletion() + public function boot(): void { - return function ($user, $mailingList) { - return response() - ->redirectTo('/'); - }; - } - - /** - * @return callable|string - */ - public function onCheckSubscriptionStatusOfMailingList() - { - return function ($user, $mailingList) { + // Note: register this route outside the 'web' middleware group to avoid CSRF token + // verification on the POST (RFC 8058 one-click) endpoint. + // The throttle defaults to '60,1' (60 requests per minute). Adjust or disable: + // Subscriber::routes(throttle: '10,1'); + // Subscriber::routes(throttle: false); + Subscriber::routes(); + + Subscriber::onUnsubscribeFromMailingList(function ($notifiable, string $mailingList) { + // Remove the notifiable's subscription to the given mailing list. + }); + + Subscriber::onUnsubscribeFromAllMailingLists(function ($notifiable) { + // Remove the notifiable's subscription to all mailing lists. + }); + + Subscriber::onCompletion(function ($notifiable, ?string $mailingList) { + // Return the response shown to the user after unsubscribing (GET requests only). + return redirect('/'); + }); + + Subscriber::onCheckSubscriptionStatusOfMailingList(function ($notifiable, string $mailingList): bool { + // Return true if the notifiable is subscribed to this mailing list. return true; - }; - } + }); - /** - * @return callable|string - */ - public function onCheckSubscriptionStatusOfAllMailingLists() - { - return function ($user) { + Subscriber::onCheckSubscriptionStatusOfAllMailingLists(function ($notifiable): bool { + // Return true if the notifiable has not globally unsubscribed. return true; - }; + }); } } diff --git a/stubs/tests/SubscribableNotificationTest.stub b/stubs/tests/SubscribableNotificationTest.stub new file mode 100644 index 0000000..1b240f6 --- /dev/null +++ b/stubs/tests/SubscribableNotificationTest.stub @@ -0,0 +1,132 @@ +alwaysSubscribed(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with your notification class + $user->notify(new \App\Notifications\YourNotification()); + + Mail::assertSentCount(1); + } + + /** + * Verify that a globally unsubscribed user does not receive the notification. + * + * Requires the same model and notification setup as the test above. + */ + public function test_globally_unsubscribed_user_does_not_receive_notification(): void + { + Mail::fake(); + Subscriber::fake()->alwaysUnsubscribed(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with your notification class + $user->notify(new \App\Notifications\YourNotification()); + + Mail::assertNothingSent(); + } + + /** + * Verify that a user unsubscribed from a specific list does not receive + * notifications that target that list. + * + * Requires your notification to implement AppliesToMailingList in addition + * to CheckNotifiableSubscriptionStatus. + */ + public function test_user_unsubscribed_from_mailing_list_does_not_receive_notification(): void + { + Mail::fake(); + + // alwaysUnsubscribed() makes both the global and per-list checks return false. + // To test only per-list gating, configure real onCheckSubscriptionStatus* handlers + // in your test setup that return false only for the specific list. + Subscriber::fake()->alwaysUnsubscribed(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with a notification that implements AppliesToMailingList + $user->notify(new \App\Notifications\YourNotification()); + + Mail::assertNothingSent(); + } + + /** + * Verify the mail message includes the expected unsubscribe view data. + * + * Tests the output of your notification's toMail() method directly, without + * going through the notification channel pipeline. + */ + public function test_notification_email_includes_unsubscribe_links(): void + { + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with your notification class + $notification = new \App\Notifications\YourNotification(); + + /** @var SubscribableMailMessage $message */ + $message = $notification->toMail($user); + + $this->assertInstanceOf(SubscribableMailMessage::class, $message); + $this->assertArrayHasKey('unsubscribeLinkForAll', $message->viewData); + } + + /** + * Verify the mail message includes a per-list unsubscribe link when the + * notification implements AppliesToMailingList. + */ + public function test_mailing_list_notification_includes_list_specific_unsubscribe_link(): void + { + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with a notification that implements AppliesToMailingList + $notification = new \App\Notifications\YourNotification(); + + /** @var SubscribableMailMessage $message */ + $message = $notification->toMail($user); + + $this->assertInstanceOf(SubscribableMailMessage::class, $message); + $this->assertArrayHasKey('unsubscribeLink', $message->viewData); + $this->assertArrayHasKey('unsubscribeLinkForAll', $message->viewData); + } +} diff --git a/stubs/tests/UnsubscribeRouteTest.stub b/stubs/tests/UnsubscribeRouteTest.stub new file mode 100644 index 0000000..50f76a4 --- /dev/null +++ b/stubs/tests/UnsubscribeRouteTest.stub @@ -0,0 +1,87 @@ +create(); + + $this->get($user->unsubscribeLink())->assertSuccessful(); + + $fake->assertUnsubscribedFromAll($user); + } + + public function test_user_can_unsubscribe_from_a_specific_mailing_list(): void + { + $fake = Subscriber::fake(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with a real mailing list identifier used by your application + $this->get($user->unsubscribeLink('your-mailing-list'))->assertSuccessful(); + + $fake->assertUnsubscribedFromMailingList($user, 'your-mailing-list'); + } + + public function test_rfc8058_one_click_post_unsubscribes_user(): void + { + $fake = Subscriber::fake(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + $this->post($user->unsubscribeLink(), ['List-Unsubscribe' => 'One-Click']) + ->assertNoContent(); + + $fake->assertUnsubscribedFromAll($user); + } + + public function test_rfc8058_one_click_post_unsubscribes_user_from_mailing_list(): void + { + $fake = Subscriber::fake(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + // TODO: Replace with a real mailing list identifier used by your application + $this->post($user->unsubscribeLink('your-mailing-list'), ['List-Unsubscribe' => 'One-Click']) + ->assertNoContent(); + + $fake->assertUnsubscribedFromMailingList($user, 'your-mailing-list'); + } + + public function test_post_without_rfc8058_body_is_rejected(): void + { + Subscriber::fake(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + $this->post($user->unsubscribeLink())->assertBadRequest(); + } + + public function test_tampered_unsubscribe_url_is_rejected(): void + { + Subscriber::fake(); + + // TODO: Replace with your notifiable model factory + $user = \App\Models\User::factory()->create(); + + $this->get($user->unsubscribeLink() . '&tampered=1')->assertForbidden(); + } +} diff --git a/tests/Channels/SubscriberMailChannelTest.php b/tests/Channels/SubscriberMailChannelTest.php deleted file mode 100644 index 3d5fc8b..0000000 --- a/tests/Channels/SubscriberMailChannelTest.php +++ /dev/null @@ -1,327 +0,0 @@ -notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $this->assertArrayHasKey('unsubscribeLink', $event->data); - $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); - - $this->assertEquals( - 'https://testing.local/unsubscribe/testing-list', - $event->data['unsubscribeLink'] - ); - $this->assertEquals( - 'https://testing.local/unsubscribe', - $event->data['unsubscribeLinkForAll'] - ); - - $this->assertTrue( - $event->message->getHeaders()->has('List-Unsubscribe') - ); - $this->assertEquals( - '', - $this->getHeaderContent($event->message, 'List-Unsubscribe') - ); - - $content = $this->getContent($event->message); - - $this->assertStringContainsString( - 'If you no longer want to receive this type of email in the future use this', - $content - ); - - $this->assertStringContainsString( - 'To no longer receive any future emails', - $content - ); - - $this->assertStringContainsString( - 'https://testing.local/unsubscribe/testing-list', - $content - ); - - $this->assertStringContainsString( - 'https://testing.local/unsubscribe', - $content - ); - - return true; - }); - } - - public function test_it_sends_mail_notifications_with_mailing_links_via_queues() - { - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $expectedNotification = new DummyNotificationWithQueuing(); - $notifiable = new DummyNotifiableWithSubscriptions(); - - $notifiable->notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); - - $this->assertEquals( - 'https://testing.local/unsubscribe', - $event->data['unsubscribeLinkForAll'] - ); - - $this->assertTrue( - $event->message->getHeaders()->has('List-Unsubscribe') - ); - - $this->assertEquals( - '', - $this->getHeaderContent($event->message, 'List-Unsubscribe') - ); - - $content = $this->getContent($event->message); - - $this->assertStringContainsString( - 'To no longer receive any future emails', - $content - ); - - $this->assertStringContainsString( - 'https://testing.local/unsubscribe', - $content - ); - - return true; - }); - } - - public function test_it_sends_mail_notifications_with_an_unsubscribe_link_for_all_emails() - { - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $expectedNotification = new DummyNotification(); - $notifiable = new DummyNotifiableWithSubscriptions(); - - $notifiable->notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); - - $this->assertEquals( - 'https://testing.local/unsubscribe', - $event->data['unsubscribeLinkForAll'] - ); - - $this->assertTrue( - $event->message->getHeaders()->has('List-Unsubscribe') - ); - $this->assertEquals( - '', - $this->getHeaderContent($event->message, 'List-Unsubscribe') - ); - - $content = $this->getContent($event->message); - - $this->assertStringContainsString( - 'To no longer receive any future emails', - $content - ); - - $this->assertStringContainsString( - 'https://testing.local/unsubscribe', - $content - ); - - return true; - }); - } - - public function test_it_sends_mail_notifications_normally_otherwise() - { - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $expectedNotification = new DummyNotification(); - $notifiable = new DummyNotifiable(); - - $notifiable->notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $this->assertArrayNotHasKey('unsubscribeLinkForAll', $event->data); - - $this->assertFalse( - $event->message->getHeaders()->has('List-Unsubscribe') - ); - - $content = $this->getContent($event->message); - - $this->assertStringNotContainsString( - 'To no longer receive any future emails', - $content - ); - - $this->assertStringNotContainsString( - 'https://testing.local/unsubscribe', - $content, - ); - - return true; - }); - } - - public function test_it_handles_mailables_as_per_inherited_behavior() - { - View::addNamespace('testing', __DIR__.'/../views'); - - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $expectedNotification = new DummyNotification(); - $expectedNotification->useMailable = true; - $notifiable = new DummyNotifiable(); - - $notifiable->notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $content = $this->getContent($event->message); - - $this->assertStringContainsString( - 'This is a dummy', - $content - ); - - return true; - }); - } - - public function test_it_checks_if_a_notifiable_is_subscribed_to_receive_the_notification() - { - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $notification = new DummyNotificationWithMailingList(); - $notifiable = new DummyNotifiableWithSubscriptions(); - - $notification->shouldCheck = true; - $notifiable->isSubscribed = false; - - $notifiable->notify($notification); - - Event::assertNotDispatched(MessageSending::class); - } - - public function test_it_does_not_send_mail_if_there_is_no_email_to_route_to() - { - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $notification = new DummyNotification(); - $notifiable = new DummyNotifiable(); - $notifiable->email = null; - - $notifiable->notify($notification); - - Event::assertNotDispatched(MessageSending::class); - } - - public function test_it_uses_views_set_on_the_mail_message_from_the_notification() - { - View::addNamespace('testing', __DIR__.'/../views'); - - Event::fake([ - MessageSending::class, - MessageSent::class, - ]); - - $expectedNotification = new DummyNotification(); - $expectedNotification->useView = 'testing::example'; - $notifiable = new DummyNotifiable(); - - $notifiable->notify($expectedNotification); - - Event::assertDispatched(MessageSending::class, function (MessageSending $event) { - $content = $this->getContent($event->message); - - $this->assertStringContainsString( - 'This is a dummy', - $content - ); - - return true; - }); - } - - /** - * @param \Swift_Message|Email $message - * @return string - */ - protected function getContent($message): string - { - if (get_class($message) === 'Swift_Message') { - return $message->getBody(); - } elseif (get_class($message) === 'Symfony\Component\Mime\Email') { - return $message->getHtmlBody(); - } - - throw new \Exception(sprintf('Could not establish Message class %s', get_class($message))); - } - - /** - * @param \Swift_Message|Email $message - * @return string - */ - protected function getHeaderContent($message, $header): string - { - if (get_class($message) === 'Swift_Message') { - return $message->getHeaders()->get($header, 0)->getValue(); - } elseif (get_class($message) === 'Symfony\Component\Mime\Email') { - return $message->getHeaders()->getHeaderBody($header); - } - - throw new \Exception(sprintf('Could not establish Message class %s', get_class($message))); - } -} diff --git a/tests/Controllers/LegacyUnsubscribeControllerTest.php b/tests/Controllers/LegacyUnsubscribeControllerTest.php new file mode 100644 index 0000000..222616a --- /dev/null +++ b/tests/Controllers/LegacyUnsubscribeControllerTest.php @@ -0,0 +1,98 @@ +loadLaravelMigrations(); + } + + protected function getPackageProviders($app) + { + return [ + SubscribableServiceProvider::class, + DummyApplicationServiceProvider::class, + ]; + } + + protected function defineEnvironment($app): void + { + // Register the legacy route pointing at DummyUser (simulating a v1 User-only setup) + Subscriber::legacyRoutes(DummyUser::class); + } + + public function test_it_unsubscribes_via_a_legacy_url_without_subscriber_type() + { + $this->withoutExceptionHandling(); + + $called = false; + $user = DummyUser::create(['name' => 'test', 'email' => 'test@testing.local', 'password' => 'test']); + + Subscriber::onUnsubscribeFromAllMailingLists(function ($notifiable) use (&$called, $user) { + $called = true; + $this->assertEquals($user->id, $notifiable->id); + }); + + $url = URL::signedRoute('unsubscribe.legacy', ['subscriberId' => $user->id]); + + $this->get($url)->assertStatus(302)->assertRedirect('/'); + + $this->assertTrue($called); + } + + public function test_it_unsubscribes_from_a_mailing_list_via_a_legacy_url() + { + $this->withoutExceptionHandling(); + + $called = false; + $user = DummyUser::create(['name' => 'test', 'email' => 'test@testing.local', 'password' => 'test']); + + Subscriber::onUnsubscribeFromMailingList(function ($notifiable, $list) use (&$called, $user) { + $called = true; + $this->assertEquals($user->id, $notifiable->id); + $this->assertEquals('newsletter', $list); + }); + + $url = URL::signedRoute('unsubscribe.legacy', ['subscriberId' => $user->id, 'mailingList' => 'newsletter']); + + $this->get($url)->assertStatus(302)->assertRedirect('/'); + + $this->assertTrue($called); + } + + public function test_it_returns_204_for_rfc8058_post_via_legacy_url() + { + $this->withoutExceptionHandling(); + + $user = DummyUser::create(['name' => 'test', 'email' => 'test@testing.local', 'password' => 'test']); + + $url = URL::signedRoute('unsubscribe.legacy', ['subscriberId' => $user->id]); + + $this->post($url, ['List-Unsubscribe' => 'One-Click'])->assertNoContent(); + } + + public function test_legacy_url_does_not_interfere_with_new_route() + { + $user = DummyUser::create(['name' => 'test', 'email' => 'test@testing.local', 'password' => 'test']); + + // A v2-style URL (subscriberType is a non-numeric string) must hit the new route, + // not the legacy one + $newUrl = $user->unsubscribeLink(); + + $this->assertStringContainsString('/unsubscribe/', $newUrl); + $this->assertMatchesRegularExpression('#/unsubscribe/\D[^/]*/[^/]+#', $newUrl); + } +} diff --git a/tests/Controllers/UnsubscribeControllerTest.php b/tests/Controllers/UnsubscribeControllerTest.php index ee42a55..2784e27 100644 --- a/tests/Controllers/UnsubscribeControllerTest.php +++ b/tests/Controllers/UnsubscribeControllerTest.php @@ -48,8 +48,6 @@ public function test_it_unsubscribes_users_from_all_mailing_lists() 'password' => 'test', ]); - Subscriber::userModel(DummyUser::class); - Subscriber::onUnsubscribeFromAllMailingLists( function ($user) use (&$expected, $expectedUser) { $expected = true; @@ -77,8 +75,6 @@ public function test_it_unsubscribes_users_from_a_mailing_list() 'password' => 'test', ]); - Subscriber::userModel(DummyUser::class); - Subscriber::onUnsubscribeFromMailingList( function ($user, $mailingList) use (&$expected, $expectedUser) { $expected = true; @@ -137,7 +133,11 @@ function () use (&$notExpected) { $this->get( URL::signedRoute( Subscriber::routeName(), - ['subscriber' => 1, 'mailingList' => 'test'] + [ + 'subscriberType' => DummyUser::class, + 'subscriberId' => 999, + 'mailingList' => 'test', + ] ) ) ->assertStatus(403); @@ -165,4 +165,79 @@ public function test_it_fires_events_for_unsubscribing() Event::assertDispatched(UserUnsubscribing::class); Event::assertDispatched(UserUnsubscribed::class); } + + public function test_it_returns_200_for_rfc8058_one_click_post_unsubscribe() + { + $this->withoutExceptionHandling(); + + /** @var DummyUser $user */ + $expectedUser = DummyUser::create([ + 'name' => 'test', + 'email' => 'test@testing.local', + 'password' => 'test', + ]); + + $called = false; + Subscriber::onUnsubscribeFromAllMailingLists(function ($user) use (&$called) { + $called = true; + }); + + $this->post($expectedUser->unsubscribeLink(), ['List-Unsubscribe' => 'One-Click']) + ->assertNoContent(); + + $this->assertTrue($called); + } + + public function test_it_returns_200_for_rfc8058_one_click_post_unsubscribe_from_mailing_list() + { + $this->withoutExceptionHandling(); + + /** @var DummyUser $user */ + $expectedUser = DummyUser::create([ + 'name' => 'test', + 'email' => 'test@testing.local', + 'password' => 'test', + ]); + + $called = false; + Subscriber::onUnsubscribeFromMailingList(function ($user, $list) use (&$called) { + $called = true; + $this->assertEquals('newsletter', $list); + }); + + $this->post($expectedUser->unsubscribeLink('newsletter'), ['List-Unsubscribe' => 'One-Click']) + ->assertNoContent(); + + $this->assertTrue($called); + } + + public function test_fake_assertions_work_when_model_is_loaded_fresh_by_the_controller() + { + // This exercises the model-identity problem: the controller loads the user via + // ->first(), producing a different object instance than $expectedUser. The fake's + // assertions must compare by primary key, not by object identity (===). + $fake = Subscriber::fake(); + + $expectedUser = DummyUser::create([ + 'name' => 'test', + 'email' => 'test@testing.local', + 'password' => 'test', + ]); + + $this->get($expectedUser->unsubscribeLink())->assertSuccessful(); + + $fake->assertUnsubscribedFromAll($expectedUser); + } + + public function test_it_rejects_post_without_rfc8058_body() + { + $expectedUser = DummyUser::create([ + 'name' => 'test', + 'email' => 'test@testing.local', + 'password' => 'test', + ]); + + $this->post($expectedUser->unsubscribeLink()) + ->assertStatus(400); + } } diff --git a/tests/MailSubscriberTest.php b/tests/MailSubscriberTest.php index aeb6051..5edc4dc 100644 --- a/tests/MailSubscriberTest.php +++ b/tests/MailSubscriberTest.php @@ -32,7 +32,7 @@ protected function getPackageProviders($app) public function test_it_generates_a_signed_url_for_users_to_unsubscribe() { - Route::get('unsubscribe/{subscriber}/{mailingList?}', function () { + Route::get('unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}', function () { })->name('unsubscribe'); /** @var DummyUser $user */ @@ -45,16 +45,18 @@ public function test_it_generates_a_signed_url_for_users_to_unsubscribe() $url = $user->unsubscribeLink(); - $this->assertEquals( - URL::signedRoute('unsubscribe', ['subscriber' => 1]), + URL::signedRoute('unsubscribe', [ + 'subscriberType' => $user->getMorphClass(), + 'subscriberId' => 1, + ]), $url ); } public function test_it_generates_a_signed_url_for_users_to_unsubscribe_from_a_mailing_list() { - Route::get('unsubscribe/{subscriber}/{mailingList?}', function () { + Route::get('unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}', function () { })->name('unsubscribe'); /** @var DummyUser $user */ @@ -68,44 +70,42 @@ public function test_it_generates_a_signed_url_for_users_to_unsubscribe_from_a_m $url = $user->unsubscribeLink('test'); $this->assertEquals( - URL::signedRoute('unsubscribe', ['subscriber' => 1, 'mailingList' => 'test']), + URL::signedRoute('unsubscribe', [ + 'subscriberType' => $user->getMorphClass(), + 'subscriberId' => 1, + 'mailingList' => 'test', + ]), $url ); } public function test_it_can_check_its_subscription_status_for_all_mailing_lists() { - /** @var DummyUser $user */ $user = DummyUser::make([ 'name' => 'test', 'email' => 'test@testing.local', 'password' => 'test', ]); - $notification = new DummyNotification(); + $fake = Subscriber::fake()->alwaysSubscribed(); - Subscriber::shouldReceive('checkSubscriptionStatus') - ->with($user, null) - ->andReturn(true); + $this->assertTrue($user->mailSubscriptionStatus(new DummyNotification())); - $this->assertTrue($user->mailSubscriptionStatus($notification)); + $fake->assertCheckedSubscriptionStatus($user, null); } public function test_it_can_check_its_subscription_status_for_one_mailing_list() { - /** @var DummyUser $user */ $user = DummyUser::make([ 'name' => 'test', 'email' => 'test@testing.local', 'password' => 'test', ]); - $notification = new DummyNotificationWithMailingList(); + $fake = Subscriber::fake()->alwaysSubscribed(); - Subscriber::shouldReceive('checkSubscriptionStatus') - ->with($user, 'testing-list') - ->andReturn(true); + $this->assertTrue($user->mailSubscriptionStatus(new DummyNotificationWithMailingList())); - $this->assertTrue($user->mailSubscriptionStatus($notification)); + $fake->assertCheckedSubscriptionStatus($user, 'testing-list'); } } diff --git a/tests/Messages/SubscribableMailMessageTest.php b/tests/Messages/SubscribableMailMessageTest.php new file mode 100644 index 0000000..df665af --- /dev/null +++ b/tests/Messages/SubscribableMailMessageTest.php @@ -0,0 +1,255 @@ +assertEquals('subscriber::html', $message->markdown); + } + + public function test_via_sets_unsubscribe_link_for_all_emails() + { + $notifiable = new DummyNotifiableWithSubscriptions(); + $message = SubscribableMailMessage::via($notifiable, new DummyNotification()); + + $this->assertArrayHasKey('unsubscribeLinkForAll', $message->viewData); + $this->assertEquals('https://testing.local/unsubscribe', $message->viewData['unsubscribeLinkForAll']); + $this->assertArrayNotHasKey('unsubscribeLink', $message->viewData); + } + + public function test_via_sets_mailing_list_specific_unsubscribe_link() + { + $notifiable = new DummyNotifiableWithSubscriptions(); + $message = SubscribableMailMessage::via($notifiable, new DummyNotificationWithMailingList()); + + $this->assertArrayHasKey('unsubscribeLink', $message->viewData); + $this->assertEquals('https://testing.local/unsubscribe/testing-list', $message->viewData['unsubscribeLink']); + $this->assertArrayHasKey('unsubscribeLinkForAll', $message->viewData); + } + + public function test_via_resolves_enum_mailing_list_to_its_string_value() + { + $notifiable = new DummyNotifiableWithSubscriptions(); + $message = SubscribableMailMessage::via($notifiable, new DummyNotificationWithEnumMailingList()); + + $this->assertEquals('https://testing.local/unsubscribe/newsletter', $message->viewData['unsubscribeLink']); + } + + public function test_via_accepts_a_custom_template() + { + $notifiable = new DummyNotifiableWithSubscriptions(); + $message = SubscribableMailMessage::via($notifiable, new DummyNotification(), 'my-package::custom'); + + $this->assertEquals('my-package::custom', $message->markdown); + } + + public function test_trait_can_be_applied_to_custom_mail_message_class() + { + $customClass = new class () extends MailMessage { + use SubscribableNotification; + }; + + $message = $customClass::via(new DummyNotifiableWithSubscriptions(), new DummyNotification()); + + $this->assertInstanceOf($customClass::class, $message); + $this->assertEquals('subscriber::html', $message->markdown); + $this->assertArrayHasKey('unsubscribeLinkForAll', $message->viewData); + } + + public function test_via_registers_a_symfony_message_callback() + { + $notifiable = new DummyNotifiableWithSubscriptions(); + $message = SubscribableMailMessage::via($notifiable, new DummyNotification()); + + $this->assertCount(1, $message->callbacks); + } + + public function test_it_sends_with_rfc8058_headers_for_mailing_list_notifications() + { + Event::fake([MessageSending::class, MessageSent::class]); + + (new DummyNotifiableWithSubscriptions())->notify(new DummyNotificationWithMailingList()); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertArrayHasKey('unsubscribeLink', $event->data); + $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); + $this->assertEquals('https://testing.local/unsubscribe/testing-list', $event->data['unsubscribeLink']); + $this->assertEquals('https://testing.local/unsubscribe', $event->data['unsubscribeLinkForAll']); + $this->assertTrue($event->message->getHeaders()->has('List-Unsubscribe')); + $this->assertEquals( + '', + $this->headerBody($event->message, 'List-Unsubscribe') + ); + $this->assertTrue($event->message->getHeaders()->has('List-Unsubscribe-Post')); + $this->assertEquals('List-Unsubscribe=One-Click', $this->headerBody($event->message, 'List-Unsubscribe-Post')); + $content = $event->message->getHtmlBody(); + $this->assertStringContainsString('If you no longer want to receive this type of email in the future use this', $content); + $this->assertStringContainsString('To no longer receive any future emails', $content); + $this->assertStringContainsString('https://testing.local/unsubscribe/testing-list', $content); + $this->assertStringContainsString('https://testing.local/unsubscribe', $content); + + return true; + }); + } + + public function test_it_sends_with_rfc8058_headers_for_queued_notifications() + { + Event::fake([MessageSending::class, MessageSent::class]); + + (new DummyNotifiableWithSubscriptions())->notify(new DummyNotificationWithQueuing()); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); + $this->assertEquals('https://testing.local/unsubscribe', $event->data['unsubscribeLinkForAll']); + $this->assertTrue($event->message->getHeaders()->has('List-Unsubscribe')); + $this->assertEquals('', $this->headerBody($event->message, 'List-Unsubscribe')); + $this->assertEquals('List-Unsubscribe=One-Click', $this->headerBody($event->message, 'List-Unsubscribe-Post')); + $this->assertStringContainsString('To no longer receive any future emails', $event->message->getHtmlBody()); + + return true; + }); + } + + public function test_it_sends_with_unsubscribe_for_all_link_when_no_mailing_list() + { + Event::fake([MessageSending::class, MessageSent::class]); + + (new DummyNotifiableWithSubscriptions())->notify(new DummyNotification()); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertArrayHasKey('unsubscribeLinkForAll', $event->data); + $this->assertEquals('https://testing.local/unsubscribe', $event->data['unsubscribeLinkForAll']); + $this->assertEquals('', $this->headerBody($event->message, 'List-Unsubscribe')); + $this->assertStringContainsString('To no longer receive any future emails', $event->message->getHtmlBody()); + + return true; + }); + } + + public function test_it_sends_without_unsubscribe_links_for_non_subscribable_notifiables() + { + Event::fake([MessageSending::class, MessageSent::class]); + + (new DummyNotifiable())->notify(new DummyNotification()); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertArrayNotHasKey('unsubscribeLinkForAll', $event->data); + $this->assertFalse($event->message->getHeaders()->has('List-Unsubscribe')); + $this->assertStringNotContainsString('To no longer receive any future emails', $event->message->getHtmlBody() ?? ''); + + return true; + }); + } + + public function test_it_sends_with_rfc8058_headers_for_enum_mailing_list() + { + Event::fake([MessageSending::class, MessageSent::class]); + + (new DummyNotifiableWithSubscriptions())->notify(new DummyNotificationWithEnumMailingList()); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertArrayHasKey('unsubscribeLink', $event->data); + $this->assertEquals('https://testing.local/unsubscribe/newsletter', $event->data['unsubscribeLink']); + $this->assertEquals( + '', + $this->headerBody($event->message, 'List-Unsubscribe') + ); + + return true; + }); + } + + public function test_it_cancels_send_when_notifiable_is_not_subscribed() + { + Event::fake([MessageSending::class, MessageSent::class]); + + $notification = new DummyNotificationWithMailingList(); + $notification->shouldCheck = true; + $notifiable = new DummyNotifiableWithSubscriptions(); + $notifiable->isSubscribed = false; + + $notifiable->notify($notification); + + Event::assertNotDispatched(MessageSending::class); + } + + public function test_it_does_not_send_when_there_is_no_email_address() + { + Event::fake([MessageSending::class, MessageSent::class]); + + $notifiable = new DummyNotifiable(); + $notifiable->email = null; + + $notifiable->notify(new DummyNotification()); + + Event::assertNotDispatched(MessageSending::class); + } + + public function test_it_handles_mailables() + { + View::addNamespace('testing', __DIR__.'/../views'); + Event::fake([MessageSending::class, MessageSent::class]); + + $notification = new DummyNotification(); + $notification->useMailable = true; + + (new DummyNotifiable())->notify($notification); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertStringContainsString('This is a dummy', $event->message->getHtmlBody() ?? ''); + + return true; + }); + } + + public function test_it_uses_a_custom_view_when_set_on_the_message() + { + View::addNamespace('testing', __DIR__.'/../views'); + Event::fake([MessageSending::class, MessageSent::class]); + + $notification = new DummyNotification(); + $notification->useView = 'testing::example'; + + (new DummyNotifiable())->notify($notification); + + Event::assertDispatched(MessageSending::class, function (MessageSending $event) { + $this->assertStringContainsString('This is a dummy', $event->message->getHtmlBody() ?? ''); + + return true; + }); + } + + protected function headerBody(Email $message, string $header): string + { + return $message->getHeaders()->getHeaderBody($header); + } +} diff --git a/tests/SubscribeApplicationServiceProviderTest.php b/tests/SubscribeApplicationServiceProviderTest.php deleted file mode 100644 index 9124bd2..0000000 --- a/tests/SubscribeApplicationServiceProviderTest.php +++ /dev/null @@ -1,42 +0,0 @@ -app); - - $provider->shouldLoadRoutes(true); - - $provider->boot(); - } - - public function test_it_can_be_configured_to_not_loads_routes() - { - Subscriber::shouldReceive('routes')->never(); - Subscriber::shouldReceive('userModel'); - Subscriber::shouldReceive('onUnsubscribeFromMailingList'); - Subscriber::shouldReceive('onUnsubscribeFromAllMailingLists'); - Subscriber::shouldReceive('onCompletion'); - Subscriber::shouldReceive('onCheckSubscriptionStatusOfMailingList'); - Subscriber::shouldReceive('onCheckSubscriptionStatusOfAllMailingLists'); - $provider = new DummyApplicationServiceProvider($this->app); - - $provider->shouldLoadRoutes(false); - - $provider->boot(); - } -} diff --git a/tests/SubscriberTest.php b/tests/SubscriberTest.php index f8135db..6861ed3 100755 --- a/tests/SubscriberTest.php +++ b/tests/SubscriberTest.php @@ -281,28 +281,6 @@ public function test_it_handles_checking_subscription_status_of_all_mailing_list $this->assertTrue($subscriber->checkSubscriptionStatus($expectedUser)); } - public function test_it_can_provide_a_user_model() - { - $subscriber = new Subscriber($this->app); - $subscriber->userModel = \YlsIdeas\SubscribableNotifications\Tests\Support\DummyUser::class; - - $this->assertEquals( - \YlsIdeas\SubscribableNotifications\Tests\Support\DummyUser::class, - $subscriber->userModel() - ); - } - - public function test_it_can_configure_a_user_model() - { - $subscriber = new Subscriber($this->app); - $subscriber->userModel(\YlsIdeas\SubscribableNotifications\Tests\Support\DummyUser::class); - - $this->assertEquals( - \YlsIdeas\SubscribableNotifications\Tests\Support\DummyUser::class, - $subscriber->userModel - ); - } - public function test_it_can_configure_a_route_for_the_unsubscribe_controller() { $subscriber = new Subscriber($this->app); @@ -316,6 +294,6 @@ public function test_it_can_configure_a_route_for_the_unsubscribe_controller() $this->assertTrue($router->getRoutes()->hasNamedRoute('unsubscribe')); /** @var \Illuminate\Routing\Route $route */ $route = $router->getRoutes()->getByName('unsubscribe'); - $this->assertEquals($route->uri, 'unsubscribe/{subscriber}/{mailingList?}'); + $this->assertEquals($route->uri, 'unsubscribe/{subscriberType}/{subscriberId}/{mailingList?}'); } } diff --git a/tests/Support/DummyApplicationServiceProvider.php b/tests/Support/DummyApplicationServiceProvider.php index bc54d9d..14139c1 100644 --- a/tests/Support/DummyApplicationServiceProvider.php +++ b/tests/Support/DummyApplicationServiceProvider.php @@ -2,65 +2,31 @@ namespace YlsIdeas\SubscribableNotifications\Tests\Support; -use YlsIdeas\SubscribableNotifications\SubscribableApplicationServiceProvider; +use Illuminate\Support\ServiceProvider; +use YlsIdeas\SubscribableNotifications\Facades\Subscriber; -class DummyApplicationServiceProvider extends SubscribableApplicationServiceProvider +class DummyApplicationServiceProvider extends ServiceProvider { - protected $model = DummyUser::class; - - protected $loadRoutes = true; - - public function shouldLoadRoutes($shouldLoad = false) + public function boot(): void { - $this->loadRoutes = $shouldLoad; - } + Subscriber::routes(); - /** - * @return \Closure - */ - public function onUnsubscribeFromMailingList() - { - return function () { - }; - } + Subscriber::onUnsubscribeFromMailingList(function () { + }); - /** - * @return \Closure - */ - public function onUnsubscribeFromAllMailingLists() - { - return function () { - }; - } + Subscriber::onUnsubscribeFromAllMailingLists(function () { + }); - /** - * @return \Closure - */ - public function onCompletion() - { - return function () { - return response() - ->redirectTo('/'); - }; - } + Subscriber::onCompletion(function () { + return redirect('/'); + }); - /** - * @return callable|string - */ - public function onCheckSubscriptionStatusOfMailingList() - { - return function () { + Subscriber::onCheckSubscriptionStatusOfMailingList(function () { return true; - }; - } + }); - /** - * @return callable|string - */ - public function onCheckSubscriptionStatusOfAllMailingLists() - { - return function () { + Subscriber::onCheckSubscriptionStatusOfAllMailingLists(function () { return true; - }; + }); } } diff --git a/tests/Support/DummyMailingList.php b/tests/Support/DummyMailingList.php new file mode 100644 index 0000000..99d2700 --- /dev/null +++ b/tests/Support/DummyMailingList.php @@ -0,0 +1,9 @@ +useView !== null) { @@ -44,7 +32,11 @@ public function toMail($notifiable) return new DummyMailable(); } - return (new MailMessage()) + $message = $notifiable instanceof CanUnsubscribe + ? SubscribableMailMessage::via($notifiable, $this) + : new MailMessage(); + + return $message ->line('The introduction to the notification.') ->action('Notification Action', url('/')) ->line('Thank you for using our application!'); diff --git a/tests/Support/DummyNotificationWithEnumMailingList.php b/tests/Support/DummyNotificationWithEnumMailingList.php new file mode 100644 index 0000000..464b88f --- /dev/null +++ b/tests/Support/DummyNotificationWithEnumMailingList.php @@ -0,0 +1,26 @@ +line('The introduction to the notification.'); + } + + public function usesMailingList(): string|\BackedEnum + { + return DummyMailingList::Newsletter; + } +} diff --git a/tests/Support/DummyNotificationWithMailingList.php b/tests/Support/DummyNotificationWithMailingList.php index 9e27e93..e5780a1 100644 --- a/tests/Support/DummyNotificationWithMailingList.php +++ b/tests/Support/DummyNotificationWithMailingList.php @@ -2,45 +2,28 @@ namespace YlsIdeas\SubscribableNotifications\Tests\Support; -use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; use YlsIdeas\SubscribableNotifications\Contracts\AppliesToMailingList; use YlsIdeas\SubscribableNotifications\Contracts\CheckNotifiableSubscriptionStatus; +use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage; class DummyNotificationWithMailingList extends Notification implements AppliesToMailingList, CheckNotifiableSubscriptionStatus { public $shouldCheck = false; - /** - * Get the notification's delivery channels. - * - * @param mixed $notifiable - * - * @return array - */ public function via($notifiable) { return ['mail']; } - /** - * Get the mail representation of the notification. - * - * @param mixed $notifiable - * - * @return \Illuminate\Notifications\Messages\MailMessage - */ - public function toMail($notifiable) + public function toMail($notifiable): SubscribableMailMessage { - return (new MailMessage()) + return SubscribableMailMessage::via($notifiable, $this) ->line('The introduction to the notification.') ->action('Notification Action', url('/')) ->line('Thank you for using our application!'); } - /** - * @return string - */ public function usesMailingList(): string { return 'testing-list'; diff --git a/tests/Support/DummyNotificationWithQueuing.php b/tests/Support/DummyNotificationWithQueuing.php index ab87120..5310914 100644 --- a/tests/Support/DummyNotificationWithQueuing.php +++ b/tests/Support/DummyNotificationWithQueuing.php @@ -4,51 +4,21 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; +use YlsIdeas\SubscribableNotifications\Messages\SubscribableMailMessage; class DummyNotificationWithQueuing extends Notification implements ShouldQueue { use Queueable; - public $useView = null; - - public $useMailable = false; - - /** - * Get the notification's delivery channels. - * - * @param mixed $notifiable - * - * @return array - */ public function via($notifiable) { return ['mail']; } - /** - * Get the mail representation of the notification. - * - * @param mixed $notifiable - * - * @return \Illuminate\Notifications\Messages\MailMessage - */ - public function toMail($notifiable) + public function toMail($notifiable): SubscribableMailMessage { - if ($this->useView !== null) { - return (new MailMessage()) - ->view($this->useView) - ->line('The introduction to the notification.') - ->action('Notification Action', url('/')) - ->line('Thank you for using our application!'); - } - - if ($this->useMailable === true) { - return new DummyMailable(); - } - - return (new MailMessage()) + return SubscribableMailMessage::via($notifiable, $this) ->line('The introduction to the notification.') ->action('Notification Action', url('/')) ->line('Thank you for using our application!'); diff --git a/tests/Testing/FakeSubscriberTest.php b/tests/Testing/FakeSubscriberTest.php new file mode 100644 index 0000000..dc96541 --- /dev/null +++ b/tests/Testing/FakeSubscriberTest.php @@ -0,0 +1,72 @@ +assertInstanceOf(FakeSubscriber::class, $fake); + } + + public function test_fake_swaps_the_underlying_implementation() + { + $fake = Subscriber::fake(); + + $this->assertSame($fake, Subscriber::getFacadeRoot()); + } + + public function test_it_records_mailing_list_unsubscribes() + { + $fake = Subscriber::fake(); + $user = new \stdClass(); + + $fake->unsubscribeFromMailingList($user, 'newsletter'); + + $fake->assertUnsubscribedFromMailingList($user, 'newsletter'); + } + + public function test_it_records_all_mail_unsubscribes() + { + $fake = Subscriber::fake(); + $user = new \stdClass(); + + $fake->unsubscribeFromAllMailingLists($user); + + $fake->assertUnsubscribedFromAll($user); + } + + public function test_assert_nothing_unsubscribed_passes_when_empty() + { + $fake = Subscriber::fake(); + + $fake->assertNothingUnsubscribed(); + } + + public function test_it_controls_subscription_status() + { + $fake = Subscriber::fake(); + + $fake->alwaysUnsubscribed(); + $this->assertFalse($fake->checkSubscriptionStatus(new \stdClass(), 'newsletter')); + + $fake->alwaysSubscribed(); + $this->assertTrue($fake->checkSubscriptionStatus(new \stdClass(), 'newsletter')); + } +}