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
```
This commit is contained in:
Víctor Falcón 2025-12-30 20:32:44 +01:00 committed by GitHub
parent 989a446f3c
commit beecff404a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 336 additions and 7 deletions

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use App\Jobs\Drip\SendSubscriptionCancelledEmailJob;
use App\Models\User;
use Illuminate\Console\Command;
class SendSubscriptionCancelledEmailCommand extends Command
{
protected $signature = 'email:subscription-cancelled {emails : Comma-separated list of user emails}';
protected $description = 'Send subscription cancellation email to one or more users';
public function handle(): int
{
$emailsInput = $this->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;
}
}

View File

@ -9,4 +9,5 @@ enum DripEmailType: string
case PromoCode = 'promo_code';
case ImportHelp = 'import_help';
case Feedback = 'feedback';
case SubscriptionCancelled = 'subscription_cancelled';
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\SubscriptionCancelledEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendSubscriptionCancelledEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user)
{
$this->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(),
]);
}
}

View File

@ -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));

View File

@ -0,0 +1,63 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class SubscriptionCancelledEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
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<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -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

View File

@ -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,
]);
}
}

View File

@ -0,0 +1,31 @@
<x-mail::message>
# 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:
<x-mail::panel>
Use code **CONTINUE50** to get **50% off** all current and future payments - works for both monthly and yearly subscriptions.
</x-mail::panel>
<x-mail::button :url="config('app.url') . '/subscribe'">
Reactivate Your Subscription
</x-mail::button>
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,<br>
Victor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -0,0 +1,71 @@
<?php
use App\Jobs\Drip\SendSubscriptionCancelledEmailJob;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use function Pest\Laravel\artisan;
beforeEach(function () {
Queue::fake();
});
test('command dispatches job for valid user email', function () {
$user = User::factory()->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);
});

View File

@ -0,0 +1,37 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendSubscriptionCancelledEmailJob;
use App\Mail\Drip\SubscriptionCancelledEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
});
test('subscription cancelled email is sent and logged', function () {
$user = User::factory()->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);
});

View File

@ -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();
});