From 7ff0eb6aef4fea17da72d470be96612ac9610a62 Mon Sep 17 00:00:00 2001 From: Shavonn Brown Date: Sun, 20 Sep 2026 19:25:51 -0400 Subject: [PATCH] feat: publish email templates --- .github/workflows/code-style.yml | 17 +++--- phpstan.neon | 11 ++++ resources/lang/en/notifications.php | 42 +++++++++++++++ resources/views/mail/invited.blade.php | 14 +++++ resources/views/mail/verify.blade.php | 18 +++++++ src/Notifications/VerifyWaitlistEmail.php | 13 +++-- src/Notifications/WaitlistInvited.php | 12 ++--- src/WaitlistServiceProvider.php | 21 ++++++++ tests/Feature/NotificationMailTest.php | 66 +++++++++++++++++++++++ 9 files changed, 195 insertions(+), 19 deletions(-) create mode 100644 resources/lang/en/notifications.php create mode 100644 resources/views/mail/invited.blade.php create mode 100644 resources/views/mail/verify.blade.php create mode 100644 tests/Feature/NotificationMailTest.php diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml index fa16f48..83cdd02 100644 --- a/.github/workflows/code-style.yml +++ b/.github/workflows/code-style.yml @@ -30,9 +30,11 @@ jobs: run: composer install --prefer-dist --no-interaction - name: Check code style (Pint) - run: | - composer require laravel/pint --dev --no-interaction - vendor/bin/pint --test + # The locked Pint, not a freshly required one: `laravel/pint` is + # already a dev dependency, and re-requiring it floats CI onto a + # version nobody runs locally. `--test` so this reports rather than + # silently reformats, which a plain `pint` would do and then pass. + run: vendor/bin/pint --test static-analysis: runs-on: ubuntu-latest @@ -52,6 +54,9 @@ jobs: run: composer install --prefer-dist --no-interaction - name: Run PHPStan - run: | - composer require phpstan/phpstan --dev --no-interaction - vendor/bin/phpstan analyse src --level=5 + # `composer analyse`, so CI runs exactly what a developer runs. The + # previous form installed phpstan separately — larastan already + # requires it — and passed `src --level=5` on the command line, which + # overrides both the paths and the level in phpstan.neon, so CI was + # analysing less code at a lower level than anyone local. + run: composer analyse diff --git a/phpstan.neon b/phpstan.neon index 81fe903..eb6bec4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,3 +6,14 @@ parameters: - src level: 7 tmpDir: /tmp/phpstan + + ignoreErrors: + # The notifications render this package's own views, named through the + # `waitlist::` namespace that `loadViewsFrom` registers at runtime. + # Analysed on its own there is no application container to register it, + # so the names cannot be resolved and every `markdown()` call reads as + # an unverifiable view. `NotificationMailTest` renders both, which is + # what actually proves they exist. + - identifier: argument.type + message: '#markdown\(\) expects view-string#' + path: src/Notifications/*.php diff --git a/resources/lang/en/notifications.php b/resources/lang/en/notifications.php new file mode 100644 index 0000000..4aee359 --- /dev/null +++ b/resources/lang/en/notifications.php @@ -0,0 +1,42 @@ + [ + 'subject' => 'Verify Your Email Address', + 'greeting' => 'Hello :name!', + 'line' => 'Please verify your email address to confirm your spot on the waitlist.', + 'action_text' => 'Verify Email', + 'footer' => 'If you did not sign up for this waitlist, you can ignore this email.', + ], + + /* + |-------------------------------------------------------------------------- + | Waitlist Invited + |-------------------------------------------------------------------------- + | + | Sent when somebody is let off the waitlist and into the application. + | + */ + + 'invited' => [ + 'subject' => "You're Invited!", + 'greeting' => 'Hello :name!', + 'line' => 'Great news! You have been invited from our waitlist.', + 'access_line' => 'You can now access our application and start using all the features.', + 'action_text' => 'Get Started', + 'footer' => 'Thank you for your patience!', + ], +]; diff --git a/resources/views/mail/invited.blade.php b/resources/views/mail/invited.blade.php new file mode 100644 index 0000000..5203d46 --- /dev/null +++ b/resources/views/mail/invited.blade.php @@ -0,0 +1,14 @@ +{{-- Sent when somebody is let off the waitlist. --}} + +# {{ __('waitlist::notifications.invited.greeting', ['name' => $name]) }} + +{{ __('waitlist::notifications.invited.line') }} + +{{ __('waitlist::notifications.invited.access_line') }} + + +{{ __('waitlist::notifications.invited.action_text') }} + + +{{ __('waitlist::notifications.invited.footer') }} + diff --git a/resources/views/mail/verify.blade.php b/resources/views/mail/verify.blade.php new file mode 100644 index 0000000..55167db --- /dev/null +++ b/resources/views/mail/verify.blade.php @@ -0,0 +1,18 @@ +{{-- + The waitlist verification email. + + Publish with `php artisan vendor:publish --tag=waitlist-views` to change the + layout; the wording lives in the translations, publishable separately with + `--tag=waitlist-lang`. +--}} + +# {{ __('waitlist::notifications.verify.greeting', ['name' => $name]) }} + +{{ __('waitlist::notifications.verify.line') }} + + +{{ __('waitlist::notifications.verify.action_text') }} + + +{{ __('waitlist::notifications.verify.footer') }} + diff --git a/src/Notifications/VerifyWaitlistEmail.php b/src/Notifications/VerifyWaitlistEmail.php index f99241c..be3a4e7 100644 --- a/src/Notifications/VerifyWaitlistEmail.php +++ b/src/Notifications/VerifyWaitlistEmail.php @@ -27,13 +27,12 @@ public function via(object $notifiable): array public function toMail(object $notifiable): MailMessage { - $url = route('waitlist.verify', ['token' => $this->entry->verification_token]); - return (new MailMessage) - ->subject('Verify Your Email Address') - ->greeting("Hello {$this->entry->name}!") - ->line('Please verify your email address to confirm your spot on the waitlist.') - ->action('Verify Email', $url) - ->line('If you did not sign up for this waitlist, you can ignore this email.'); + ->subject(__('waitlist::notifications.verify.subject')) + ->markdown('waitlist::mail.verify', [ + 'name' => $this->entry->name, + 'url' => route('waitlist.verify', ['token' => $this->entry->verification_token]), + 'entry' => $this->entry, + ]); } } diff --git a/src/Notifications/WaitlistInvited.php b/src/Notifications/WaitlistInvited.php index 7a67b1b..a11aa7c 100644 --- a/src/Notifications/WaitlistInvited.php +++ b/src/Notifications/WaitlistInvited.php @@ -28,11 +28,11 @@ public function via(object $notifiable): array public function toMail(object $notifiable): MailMessage { return (new MailMessage) - ->subject('You\'re Invited!') - ->greeting("Hello {$this->entry->name}!") - ->line('Great news! You have been invited from our waitlist.') - ->line('You can now access our application and start using all the features.') - ->action('Get Started', url('/')) - ->line('Thank you for your patience!'); + ->subject(__('waitlist::notifications.invited.subject')) + ->markdown('waitlist::mail.invited', [ + 'name' => $this->entry->name, + 'url' => url('/'), + 'entry' => $this->entry, + ]); } } diff --git a/src/WaitlistServiceProvider.php b/src/WaitlistServiceProvider.php index 53e2379..22487b0 100644 --- a/src/WaitlistServiceProvider.php +++ b/src/WaitlistServiceProvider.php @@ -37,6 +37,13 @@ public function register(): void public function boot(): void { + /* + * Outside the console guard below: an application needs these to + * render mail on a web request, not only when publishing. + */ + $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'waitlist'); + $this->loadViewsFrom(__DIR__.'/../resources/views', 'waitlist'); + $this->registerRoutes(); $this->registerListeners(); @@ -52,6 +59,20 @@ public function boot(): void $this->publishesMigrations([ __DIR__.'/../database/migrations' => database_path('migrations'), ], 'waitlist-migrations'); + + /* + * The two emails a waitlist signup actually receives. Their words + * used to be hardcoded in the notifications, where nothing + * downstream could reach them — and both classes are `final`, so + * an application could not even subclass its way out. + */ + $this->publishes([ + __DIR__.'/../resources/lang' => $this->app->langPath('vendor/waitlist'), + ], 'waitlist-lang'); + + $this->publishes([ + __DIR__.'/../resources/views' => $this->app->resourcePath('views/vendor/waitlist'), + ], 'waitlist-views'); } } diff --git a/tests/Feature/NotificationMailTest.php b/tests/Feature/NotificationMailTest.php new file mode 100644 index 0000000..30c9a6a --- /dev/null +++ b/tests/Feature/NotificationMailTest.php @@ -0,0 +1,66 @@ + true]); + + $entry = Waitlist::for('beta')->add('Ada Lovelace', 'ada@example.com'); + + $mail = (new VerifyWaitlistEmail($entry))->toMail($entry); + $html = (string) $mail->render(); + + expect($mail->subject)->toBe(__('waitlist::notifications.verify.subject')) + // The name is interpolated, and the token has to survive into the link. + ->and($html)->toContain('Ada Lovelace') + ->and($html)->toContain($entry->verification_token) + ->and($html)->toContain(__('waitlist::notifications.verify.action_text')) + ->and($html)->not->toContain('waitlist::'); +}); + +it('renders the invitation email', function (): void { + $entry = Waitlist::for('beta')->add('Grace Hopper', 'grace@example.com'); + + $mail = (new WaitlistInvited($entry))->toMail($entry); + $html = (string) $mail->render(); + + expect($mail->subject)->toBe(__('waitlist::notifications.invited.subject')) + ->and($html)->toContain('Grace Hopper') + ->and($html)->toContain(__('waitlist::notifications.invited.action_text')) + ->and($html)->not->toContain('waitlist::'); +}); + +/* + * The point of the exercise: an application can take both over. + */ +it('offers the views and the copy for publishing', function (): void { + expect(ServiceProvider::pathsToPublish(WaitlistServiceProvider::class, 'waitlist-views'))->not->toBeEmpty() + ->and(ServiceProvider::pathsToPublish(WaitlistServiceProvider::class, 'waitlist-lang'))->not->toBeEmpty() + ->and(view()->exists('waitlist::mail.verify'))->toBeTrue() + ->and(view()->exists('waitlist::mail.invited'))->toBeTrue(); +});