From beecff404afeae84a6cb792452e9e90f06bb9214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 30 Dec 2025 20:32:44 +0100 Subject: [PATCH] Add subscription cancellation email with manual trigger command (#47) ## Summary - Add a subscription cancellation email that can be manually triggered via artisan command - Include CONTINUE50 coupon code (50% off current and future payments for monthly/yearly subscriptions) - Add option to disable all drip emails via `DRIP_EMAILS_ENABLED` env var ## Usage ```bash # Send to single user php artisan email:subscription-cancelled user@example.com # Send to multiple users php artisan email:subscription-cancelled user1@example.com,user2@example.com # Disable all drip emails DRIP_EMAILS_ENABLED=false ``` --- .env.example | 3 + README.md | 15 ++-- .../SendSubscriptionCancelledEmailCommand.php | 49 +++++++++++++ app/Enums/DripEmailType.php | 1 + .../SendSubscriptionCancelledEmailJob.php | 39 ++++++++++ app/Listeners/ScheduleDripEmailsListener.php | 4 ++ app/Mail/Drip/SubscriptionCancelledEmail.php | 63 ++++++++++++++++ config/mail.php | 13 ++++ database/factories/UserMailLogFactory.php | 7 ++ .../drip/subscription-cancelled.blade.php | 31 ++++++++ ...dSubscriptionCancelledEmailCommandTest.php | 71 +++++++++++++++++++ .../SendSubscriptionCancelledEmailJobTest.php | 37 ++++++++++ .../ScheduleDripEmailsListenerTest.php | 10 +++ 13 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 app/Console/Commands/SendSubscriptionCancelledEmailCommand.php create mode 100644 app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php create mode 100644 app/Mail/Drip/SubscriptionCancelledEmail.php create mode 100644 resources/views/mail/drip/subscription-cancelled.blade.php create mode 100644 tests/Feature/Console/SendSubscriptionCancelledEmailCommandTest.php create mode 100644 tests/Feature/Jobs/Drip/SendSubscriptionCancelledEmailJobTest.php diff --git a/.env.example b/.env.example index 93f186c2..55fb239d 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,9 @@ MAIL_FROM_NAME="Whisper Money" # Resend Email Service (set MAIL_MAILER=resend to use in production) RESEND_API_KEY= +# Drip Emails (welcome, onboarding, promo codes, etc.) +DRIP_EMAILS_ENABLED=true + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/README.md b/README.md index 73399062..f85cdf85 100644 --- a/README.md +++ b/README.md @@ -132,13 +132,14 @@ The template includes: ### Optional Environment Variables -| Variable | Default | Description | -| ----------------------- | ------- | ------------------------------------------- | -| `HIDE_AUTH_BUTTONS` | `false` | Hide login/register buttons on landing page | -| `SUBSCRIPTIONS_ENABLED` | `false` | Enable Stripe subscriptions | -| `STRIPE_KEY` | - | Stripe publishable key | -| `STRIPE_SECRET` | - | Stripe secret key | -| `STRIPE_WEBHOOK_SECRET` | - | Stripe webhook signing secret | +| Variable | Default | Description | +| ----------------------- | ------- | -------------------------------------------------- | +| `DRIP_EMAILS_ENABLED` | `true` | Enable drip emails (welcome, onboarding, feedback) | +| `HIDE_AUTH_BUTTONS` | `false` | Hide login/register buttons on landing page | +| `SUBSCRIPTIONS_ENABLED` | `false` | Enable Stripe subscriptions | +| `STRIPE_KEY` | - | Stripe publishable key | +| `STRIPE_SECRET` | - | Stripe secret key | +| `STRIPE_WEBHOOK_SECRET` | - | Stripe webhook signing secret | ## License diff --git a/app/Console/Commands/SendSubscriptionCancelledEmailCommand.php b/app/Console/Commands/SendSubscriptionCancelledEmailCommand.php new file mode 100644 index 00000000..07c8e4ce --- /dev/null +++ b/app/Console/Commands/SendSubscriptionCancelledEmailCommand.php @@ -0,0 +1,49 @@ +argument('emails'); + $emails = array_map('trim', explode(',', $emailsInput)); + + $results = []; + + foreach ($emails as $email) { + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + $results[] = [$email, 'Invalid email format']; + + continue; + } + + $user = User::where('email', $email)->first(); + + if (! $user) { + $results[] = [$email, 'User not found']; + + continue; + } + + SendSubscriptionCancelledEmailJob::dispatch($user); + $results[] = [$email, 'Queued']; + } + + $this->table(['Email', 'Status'], $results); + + $queuedCount = collect($results)->where(fn ($r) => $r[1] === 'Queued')->count(); + $this->newLine(); + $this->info("Queued {$queuedCount} email(s) for sending."); + + return self::SUCCESS; + } +} diff --git a/app/Enums/DripEmailType.php b/app/Enums/DripEmailType.php index 6167b6a1..22ff205d 100644 --- a/app/Enums/DripEmailType.php +++ b/app/Enums/DripEmailType.php @@ -9,4 +9,5 @@ enum DripEmailType: string case PromoCode = 'promo_code'; case ImportHelp = 'import_help'; case Feedback = 'feedback'; + case SubscriptionCancelled = 'subscription_cancelled'; } diff --git a/app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php b/app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php new file mode 100644 index 00000000..1e64c824 --- /dev/null +++ b/app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php @@ -0,0 +1,39 @@ +onQueue('emails'); + } + + public function handle(): void + { + if ($this->user->hasReceivedEmail(DripEmailType::SubscriptionCancelled)) { + return; + } + + Mail::to($this->user)->send(new SubscriptionCancelledEmail($this->user)); + + UserMailLog::create([ + 'user_id' => $this->user->id, + 'email_type' => DripEmailType::SubscriptionCancelled, + 'sent_at' => now(), + ]); + } +} diff --git a/app/Listeners/ScheduleDripEmailsListener.php b/app/Listeners/ScheduleDripEmailsListener.php index 781537bc..0fa75ad1 100644 --- a/app/Listeners/ScheduleDripEmailsListener.php +++ b/app/Listeners/ScheduleDripEmailsListener.php @@ -13,6 +13,10 @@ class ScheduleDripEmailsListener { public function handle(Registered $event): void { + if (! config('mail.drip_emails_enabled')) { + return; + } + $user = $event->user; SendWelcomeEmailJob::dispatch($user)->delay(now()->addMinutes(30)); diff --git a/app/Mail/Drip/SubscriptionCancelledEmail.php b/app/Mail/Drip/SubscriptionCancelledEmail.php new file mode 100644 index 00000000..f9c7426a --- /dev/null +++ b/app/Mail/Drip/SubscriptionCancelledEmail.php @@ -0,0 +1,63 @@ + + */ + public $backoff = [2, 5, 10, 30]; + + public function __construct(public User $user) + { + $this->onQueue('emails'); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: "We're sorry to see you go", + )->from(config('mail.from.address', 'hello@example.com'), 'Victor'); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.drip.subscription-cancelled', + with: [ + 'userName' => $this->user->name, + ], + ); + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new RateLimited('emails'))->releaseAfter(1)]; + } +} diff --git a/config/mail.php b/config/mail.php index 522b284b..457ad3f0 100644 --- a/config/mail.php +++ b/config/mail.php @@ -2,6 +2,19 @@ return [ + /* + |-------------------------------------------------------------------------- + | Drip Emails + |-------------------------------------------------------------------------- + | + | This option controls whether drip emails are sent to new users upon + | registration. When disabled, no welcome, onboarding, promo code, + | import help, or feedback emails will be scheduled. + | + */ + + 'drip_emails_enabled' => env('DRIP_EMAILS_ENABLED', true), + /* |-------------------------------------------------------------------------- | Default Mailer diff --git a/database/factories/UserMailLogFactory.php b/database/factories/UserMailLogFactory.php index 67828319..f8c55a3c 100644 --- a/database/factories/UserMailLogFactory.php +++ b/database/factories/UserMailLogFactory.php @@ -59,4 +59,11 @@ class UserMailLogFactory extends Factory 'email_type' => DripEmailType::Feedback, ]); } + + public function subscriptionCancelled(): static + { + return $this->state(fn (array $attributes) => [ + 'email_type' => DripEmailType::SubscriptionCancelled, + ]); + } } diff --git a/resources/views/mail/drip/subscription-cancelled.blade.php b/resources/views/mail/drip/subscription-cancelled.blade.php new file mode 100644 index 00000000..d561cc2e --- /dev/null +++ b/resources/views/mail/drip/subscription-cancelled.blade.php @@ -0,0 +1,31 @@ + +# We're sorry to see you go, {{ $userName }} + +Hi! It's Victor, the founder of Whisper Money. I noticed you've cancelled your subscription, and I wanted to reach out personally. + +First, thank you for giving Whisper Money a try. I hope it helped you get a better handle on your finances while keeping your data private. + +## Before you go... + +If there's anything that didn't work well for you, or if you have suggestions for improvement, I'd genuinely love to hear about it. As a solo founder, your feedback is invaluable in making Whisper Money better. + +If you'd like to come back, here's a special offer just for you: + + +Use code **CONTINUE50** to get **50% off** all current and future payments - works for both monthly and yearly subscriptions. + + + +Reactivate Your Subscription + + +Your data and settings will be preserved, so you can pick up right where you left off. + +If you have any questions or just want to chat, simply reply to this email. I read and respond to every message personally. + +Thanks again for being part of this journey! + +Best,
+Victor F,
+Founder of Whisper Money +
diff --git a/tests/Feature/Console/SendSubscriptionCancelledEmailCommandTest.php b/tests/Feature/Console/SendSubscriptionCancelledEmailCommandTest.php new file mode 100644 index 00000000..a4e9a883 --- /dev/null +++ b/tests/Feature/Console/SendSubscriptionCancelledEmailCommandTest.php @@ -0,0 +1,71 @@ +create(); + + artisan('email:subscription-cancelled', ['emails' => $user->email]) + ->assertSuccessful(); + + Queue::assertPushed(SendSubscriptionCancelledEmailJob::class, function ($job) use ($user) { + return $job->user->id === $user->id; + }); +}); + +test('command dispatches jobs for multiple user emails', function () { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + artisan('email:subscription-cancelled', ['emails' => "{$user1->email},{$user2->email}"]) + ->assertSuccessful(); + + Queue::assertPushed(SendSubscriptionCancelledEmailJob::class, function ($job) use ($user1) { + return $job->user->id === $user1->id; + }); + + Queue::assertPushed(SendSubscriptionCancelledEmailJob::class, function ($job) use ($user2) { + return $job->user->id === $user2->id; + }); +}); + +test('command handles non-existent user gracefully', function () { + artisan('email:subscription-cancelled', ['emails' => 'nonexistent@example.com']) + ->assertSuccessful(); + + Queue::assertNothingPushed(); +}); + +test('command handles invalid email format gracefully', function () { + artisan('email:subscription-cancelled', ['emails' => 'not-an-email']) + ->assertSuccessful(); + + Queue::assertNothingPushed(); +}); + +test('command handles mixed valid and invalid emails', function () { + $user = User::factory()->create(); + + artisan('email:subscription-cancelled', ['emails' => "invalid,{$user->email},nonexistent@example.com"]) + ->assertSuccessful(); + + Queue::assertPushed(SendSubscriptionCancelledEmailJob::class, fn ($job) => $job->user->id === $user->id); + Queue::assertCount(1); +}); + +test('job is dispatched to emails queue', function () { + $user = User::factory()->create(); + + artisan('email:subscription-cancelled', ['emails' => $user->email]) + ->assertSuccessful(); + + Queue::assertPushedOn('emails', SendSubscriptionCancelledEmailJob::class); +}); diff --git a/tests/Feature/Jobs/Drip/SendSubscriptionCancelledEmailJobTest.php b/tests/Feature/Jobs/Drip/SendSubscriptionCancelledEmailJobTest.php new file mode 100644 index 00000000..11c0a5aa --- /dev/null +++ b/tests/Feature/Jobs/Drip/SendSubscriptionCancelledEmailJobTest.php @@ -0,0 +1,37 @@ +create(); + + SendSubscriptionCancelledEmailJob::dispatchSync($user); + + Mail::assertQueued(SubscriptionCancelledEmail::class, function ($mail) use ($user) { + return $mail->hasTo($user->email); + }); + + $this->assertDatabaseHas('user_mail_logs', [ + 'user_id' => $user->id, + 'email_type' => DripEmailType::SubscriptionCancelled->value, + ]); +}); + +test('subscription cancelled email is not sent if already received', function () { + $user = User::factory()->create(); + + UserMailLog::factory()->for($user)->subscriptionCancelled()->create(); + + SendSubscriptionCancelledEmailJob::dispatchSync($user); + + Mail::assertNotQueued(SubscriptionCancelledEmail::class); +}); diff --git a/tests/Feature/Listeners/ScheduleDripEmailsListenerTest.php b/tests/Feature/Listeners/ScheduleDripEmailsListenerTest.php index dd1aa11a..554030fe 100644 --- a/tests/Feature/Listeners/ScheduleDripEmailsListenerTest.php +++ b/tests/Feature/Listeners/ScheduleDripEmailsListenerTest.php @@ -80,3 +80,13 @@ test('all jobs are dispatched to the emails queue', function () { Queue::assertPushedOn('emails', SendImportHelpEmailJob::class); Queue::assertPushedOn('emails', SendFeedbackEmailJob::class); }); + +test('no drip emails are dispatched when disabled', function () { + config(['mail.drip_emails_enabled' => false]); + + $user = User::factory()->create(); + + event(new Registered($user)); + + Queue::assertNothingPushed(); +});