From 683b3f32a7a1467a9fd2e269903570c33164ff83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 9 Jan 2026 09:33:19 +0100 Subject: [PATCH] feat: Send custom emails to users (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview This PR adds a flexible system for sending update emails to all Whisper Money users. As the solo developer, you can now easily communicate product updates, announcements, and news to your growing user base. ## Problem Solved - **No easy way to send announcements**: Previously, there was no simple way to broadcast important updates to all users - **Manual process**: Would require writing custom scripts each time - **No duplicate prevention**: Risk of accidentally sending the same email multiple times - **Version control**: Email content wasn't tracked in git ## Solution A complete email system that lets you: 1. Write update emails as markdown templates (version controlled) 2. Send them with a single command 3. Automatic duplicate prevention (users only receive each update once) 4. Track who received what in the database ## How It Works ### 1. Create Email Template Create a markdown file in `resources/views/mail/updates/`: ```blade # What's New in January 2026 Hi {{ $user->name }}, Your update content here... Join the Discord Community Víctor Falcón Ruíz Founder & Solo Developer, Whisper Money ``` ### 2. Send to All Users ```bash # Simple - one command php artisan email:update first-update-jan-2026 # With options php artisan email:update first-update-jan-2026 --subject="Personal Thank You" --exclude-demo ``` ### 3. Automatic Tracking - Users only receive each update once (tracked by identifier) - Can send different updates to same users - View history in `user_mail_logs` table ## First Email Included The PR includes the first update email (`first-update-jan-2026`) announcing: - 240+ GitHub stars milestone - First paying users - Discord community invitation - Canny roadmap links - Personal message from Víctor as the solo developer ## Technical Details ### What's New **Database:** - Migration: Add `email_identifier` column to `user_mail_logs` - Migration: Update unique constraint to `(user_id, email_type, email_identifier)` **Backend:** - `DripEmailType` enum: Added `Update` case - `UpdateEmail` mailable: Generic mailable accepting any view - `SendUpdateEmailJob`: Handles sending + duplicate prevention - `SendUpdateEmailCommand`: Artisan command with progress bar - Updated all 6 drip email jobs to support new constraint **Frontend:** - Email template directory: `resources/views/mail/updates/` - Comprehensive README with examples **Tests:** - Complete test suite: 11 tests covering all scenarios - Tests duplicate prevention, filtering, queue behavior ### Command Options ```bash php artisan email:update [identifier] [options] Arguments: view Template name (e.g., "jan-2026-updates") identifier Tracking ID (defaults to view name) Options: --subject= Custom email subject --exclude-demo Skip demo account --force Skip confirmation prompt ``` ## Benefits 1. **Simple**: One command to send any update email 2. **Flexible**: Just create a new markdown view, no code changes needed 3. **Safe**: Confirmation prompt and duplicate prevention 4. **Fast**: Queued delivery, non-blocking 5. **Tracked**: Full audit trail in UserMailLog 6. **Consistent**: Follows existing drip email patterns 7. **Reusable**: Same command for all future update emails 8. **Version Controlled**: Email content is in git, reviewable ## Migration Path To use in production: ```bash # 1. Run migrations php artisan migrate # 2. Create your email template vim resources/views/mail/updates/your-update.blade.php # 3. Commit and deploy git add resources/views/mail/updates/your-update.blade.php git commit -m "Add update email" git push # 4. Send on production php artisan email:update your-update ``` ## Example Use Cases - Product announcements - New feature launches - Community updates - Milestone celebrations - Important notifications - Partnership announcements ## Files Changed ### Created - `database/migrations/*_add_email_identifier_to_user_mail_logs.php` - `database/migrations/*_update_user_mail_logs_unique_constraint.php` - `app/Mail/UpdateEmail.php` - `app/Jobs/SendUpdateEmailJob.php` - `app/Console/Commands/SendUpdateEmailCommand.php` - `resources/views/mail/updates/first-update-jan-2026.blade.php` - `resources/views/mail/updates/README.md` - `tests/Feature/Console/SendUpdateEmailCommandTest.php` ### Modified - `app/Enums/DripEmailType.php` (added Update case) - `app/Models/UserMailLog.php` (added email_identifier field) - All 6 drip email jobs (added email_identifier support) ## Testing All tests passing (11 tests, 27 assertions): - ✅ Command dispatches jobs for all users - ✅ Command excludes demo account when flag is set - ✅ Command fails when view does not exist - ✅ Command uses custom subject when provided - ✅ Job is dispatched to emails queue - ✅ Command handles empty user database gracefully - ✅ Job skips users who already received the update - ✅ Job creates mail log entry after sending - ✅ Job sends email with correct view and user data - ✅ Command requires confirmation by default - ✅ Command skips confirmation with force flag --- .../Commands/SendUpdateEmailCommand.php | 94 ++++++++ app/Enums/DripEmailType.php | 1 + app/Jobs/Drip/SendFeedbackEmailJob.php | 1 + app/Jobs/Drip/SendImportHelpEmailJob.php | 1 + .../Drip/SendOnboardingReminderEmailJob.php | 1 + app/Jobs/Drip/SendPromoCodeEmailJob.php | 1 + .../SendSubscriptionCancelledEmailJob.php | 1 + app/Jobs/Drip/SendWelcomeEmailJob.php | 1 + app/Jobs/SendUpdateEmailJob.php | 68 ++++++ app/Mail/UpdateEmail.php | 66 ++++++ app/Models/UserMailLog.php | 2 + database/factories/UserMailLogFactory.php | 11 +- ...add_email_identifier_to_user_mail_logs.php | 28 +++ ...pdate_user_mail_logs_unique_constraint.php | 79 +++++++ resources/views/mail/updates/.gitkeep | 0 resources/views/mail/updates/README.md | 129 +++++++++++ .../updates/first-update-jan-2026.blade.php | 43 ++++ .../Console/SendUpdateEmailCommandTest.php | 206 ++++++++++++++++++ 18 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/SendUpdateEmailCommand.php create mode 100644 app/Jobs/SendUpdateEmailJob.php create mode 100644 app/Mail/UpdateEmail.php create mode 100644 database/migrations/2026_01_08_192757_add_email_identifier_to_user_mail_logs.php create mode 100644 database/migrations/2026_01_08_204223_update_user_mail_logs_unique_constraint.php create mode 100644 resources/views/mail/updates/.gitkeep create mode 100644 resources/views/mail/updates/README.md create mode 100644 resources/views/mail/updates/first-update-jan-2026.blade.php create mode 100644 tests/Feature/Console/SendUpdateEmailCommandTest.php diff --git a/app/Console/Commands/SendUpdateEmailCommand.php b/app/Console/Commands/SendUpdateEmailCommand.php new file mode 100644 index 00000000..0e6fa525 --- /dev/null +++ b/app/Console/Commands/SendUpdateEmailCommand.php @@ -0,0 +1,94 @@ +argument('view'); + $identifier = $this->argument('identifier') ?? $viewName; + $subject = $this->option('subject') ?? 'Update from Whisper Money'; + + if ($this->option('force')) { + $identifier = $identifier.'_force_'.time(); + } + + $viewPath = resource_path("views/mail/updates/{$viewName}.blade.php"); + + if (! File::exists($viewPath)) { + $this->error("View file not found: {$viewPath}"); + $this->info("Please create the view at: resources/views/mail/updates/{$viewName}.blade.php"); + + return self::FAILURE; + } + + $users = User::query()->get(); + + if ($this->option('exclude-demo')) { + $users = $users->filter(fn (User $user) => ! $user->isDemoAccount()); + } + + if ($users->isEmpty()) { + $this->info('No users found in the database.'); + + return self::SUCCESS; + } + + $this->info("Found {$users->count()} user(s)."); + + if (! $this->option('force')) { + if (! $this->confirm("About to send '{$identifier}' email to {$users->count()} user(s). Continue?", true)) { + $this->info('Cancelled.'); + + return self::SUCCESS; + } + } + + $this->info('Queueing update emails...'); + + $progressBar = $this->output->createProgressBar($users->count()); + $progressBar->start(); + + $queued = 0; + foreach ($users as $user) { + SendUpdateEmailJob::dispatch($user, $viewName, $identifier, $subject); + $queued++; + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(); + + $this->info("Successfully queued {$queued} update email(s) to the 'emails' queue!"); + + return self::SUCCESS; + } +} diff --git a/app/Enums/DripEmailType.php b/app/Enums/DripEmailType.php index 22ff205d..611d58d9 100644 --- a/app/Enums/DripEmailType.php +++ b/app/Enums/DripEmailType.php @@ -10,4 +10,5 @@ enum DripEmailType: string case ImportHelp = 'import_help'; case Feedback = 'feedback'; case SubscriptionCancelled = 'subscription_cancelled'; + case Update = 'update'; } diff --git a/app/Jobs/Drip/SendFeedbackEmailJob.php b/app/Jobs/Drip/SendFeedbackEmailJob.php index 2b406730..7bf7f779 100644 --- a/app/Jobs/Drip/SendFeedbackEmailJob.php +++ b/app/Jobs/Drip/SendFeedbackEmailJob.php @@ -37,6 +37,7 @@ class SendFeedbackEmailJob implements ShouldQueue UserMailLog::create([ 'user_id' => $this->user->id, 'email_type' => DripEmailType::Feedback, + 'email_identifier' => DripEmailType::Feedback->value, 'sent_at' => now(), ]); } diff --git a/app/Jobs/Drip/SendImportHelpEmailJob.php b/app/Jobs/Drip/SendImportHelpEmailJob.php index 30a6758f..6314028d 100644 --- a/app/Jobs/Drip/SendImportHelpEmailJob.php +++ b/app/Jobs/Drip/SendImportHelpEmailJob.php @@ -45,6 +45,7 @@ class SendImportHelpEmailJob implements ShouldQueue UserMailLog::create([ 'user_id' => $this->user->id, 'email_type' => DripEmailType::ImportHelp, + 'email_identifier' => DripEmailType::ImportHelp->value, 'sent_at' => now(), ]); } diff --git a/app/Jobs/Drip/SendOnboardingReminderEmailJob.php b/app/Jobs/Drip/SendOnboardingReminderEmailJob.php index 3269e4b9..5d92235e 100644 --- a/app/Jobs/Drip/SendOnboardingReminderEmailJob.php +++ b/app/Jobs/Drip/SendOnboardingReminderEmailJob.php @@ -37,6 +37,7 @@ class SendOnboardingReminderEmailJob implements ShouldQueue UserMailLog::create([ 'user_id' => $this->user->id, 'email_type' => DripEmailType::OnboardingReminder, + 'email_identifier' => DripEmailType::OnboardingReminder->value, 'sent_at' => now(), ]); } diff --git a/app/Jobs/Drip/SendPromoCodeEmailJob.php b/app/Jobs/Drip/SendPromoCodeEmailJob.php index 365c4a8b..ef5c39c7 100644 --- a/app/Jobs/Drip/SendPromoCodeEmailJob.php +++ b/app/Jobs/Drip/SendPromoCodeEmailJob.php @@ -45,6 +45,7 @@ class SendPromoCodeEmailJob implements ShouldQueue UserMailLog::create([ 'user_id' => $this->user->id, 'email_type' => DripEmailType::PromoCode, + 'email_identifier' => DripEmailType::PromoCode->value, 'sent_at' => now(), ]); } diff --git a/app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php b/app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php index 1e64c824..08eb1038 100644 --- a/app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php +++ b/app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php @@ -33,6 +33,7 @@ class SendSubscriptionCancelledEmailJob implements ShouldQueue UserMailLog::create([ 'user_id' => $this->user->id, 'email_type' => DripEmailType::SubscriptionCancelled, + 'email_identifier' => DripEmailType::SubscriptionCancelled->value, 'sent_at' => now(), ]); } diff --git a/app/Jobs/Drip/SendWelcomeEmailJob.php b/app/Jobs/Drip/SendWelcomeEmailJob.php index 79d0da04..0d3739ab 100644 --- a/app/Jobs/Drip/SendWelcomeEmailJob.php +++ b/app/Jobs/Drip/SendWelcomeEmailJob.php @@ -33,6 +33,7 @@ class SendWelcomeEmailJob implements ShouldQueue UserMailLog::create([ 'user_id' => $this->user->id, 'email_type' => DripEmailType::Welcome, + 'email_identifier' => DripEmailType::Welcome->value, 'sent_at' => now(), ]); } diff --git a/app/Jobs/SendUpdateEmailJob.php b/app/Jobs/SendUpdateEmailJob.php new file mode 100644 index 00000000..e9f5e591 --- /dev/null +++ b/app/Jobs/SendUpdateEmailJob.php @@ -0,0 +1,68 @@ + + */ + public $backoff = [2, 5, 10, 30]; + + public function __construct( + public User $user, + public string $viewName, + public string $emailIdentifier, + public string $subject = 'Update from Whisper Money' + ) { + $this->onQueue('emails'); + } + + public function handle(): void + { + if ($this->hasReceivedUpdate()) { + return; + } + + Mail::to($this->user)->send( + new UpdateEmail($this->user, $this->viewName, $this->subject) + ); + + UserMailLog::create([ + 'user_id' => $this->user->id, + 'email_type' => DripEmailType::Update, + 'email_identifier' => $this->emailIdentifier, + 'sent_at' => now(), + ]); + } + + protected function hasReceivedUpdate(): bool + { + return UserMailLog::where('user_id', $this->user->id) + ->where('email_type', DripEmailType::Update) + ->where('email_identifier', $this->emailIdentifier) + ->exists(); + } +} diff --git a/app/Mail/UpdateEmail.php b/app/Mail/UpdateEmail.php new file mode 100644 index 00000000..01633f9e --- /dev/null +++ b/app/Mail/UpdateEmail.php @@ -0,0 +1,66 @@ + + */ + public $backoff = [2, 5, 10, 30]; + + public function __construct( + public User $user, + public string $viewName, + public string $emailSubject = 'Update from Whisper Money' + ) { + $this->onQueue('emails'); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: $this->emailSubject, + )->from(config('mail.from.address', 'hello@example.com'), 'Victor'); + } + + public function content(): Content + { + return new Content( + markdown: "mail.updates.{$this->viewName}", + with: [ + 'user' => $this->user, + ], + ); + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new RateLimited('emails'))->releaseAfter(1)]; + } +} diff --git a/app/Models/UserMailLog.php b/app/Models/UserMailLog.php index 68d1d0f0..24622a56 100644 --- a/app/Models/UserMailLog.php +++ b/app/Models/UserMailLog.php @@ -15,6 +15,7 @@ class UserMailLog extends Model protected $fillable = [ 'user_id', 'email_type', + 'email_identifier', 'sent_at', ]; @@ -22,6 +23,7 @@ class UserMailLog extends Model { return [ 'email_type' => DripEmailType::class, + 'email_identifier' => 'string', 'sent_at' => 'datetime', ]; } diff --git a/database/factories/UserMailLogFactory.php b/database/factories/UserMailLogFactory.php index f8c55a3c..deebb270 100644 --- a/database/factories/UserMailLogFactory.php +++ b/database/factories/UserMailLogFactory.php @@ -18,9 +18,12 @@ class UserMailLogFactory extends Factory */ public function definition(): array { + $emailType = fake()->randomElement(DripEmailType::cases()); + return [ 'user_id' => User::factory(), - 'email_type' => fake()->randomElement(DripEmailType::cases()), + 'email_type' => $emailType, + 'email_identifier' => $emailType->value, 'sent_at' => now(), ]; } @@ -29,6 +32,7 @@ class UserMailLogFactory extends Factory { return $this->state(fn (array $attributes) => [ 'email_type' => DripEmailType::Welcome, + 'email_identifier' => DripEmailType::Welcome->value, ]); } @@ -36,6 +40,7 @@ class UserMailLogFactory extends Factory { return $this->state(fn (array $attributes) => [ 'email_type' => DripEmailType::OnboardingReminder, + 'email_identifier' => DripEmailType::OnboardingReminder->value, ]); } @@ -43,6 +48,7 @@ class UserMailLogFactory extends Factory { return $this->state(fn (array $attributes) => [ 'email_type' => DripEmailType::PromoCode, + 'email_identifier' => DripEmailType::PromoCode->value, ]); } @@ -50,6 +56,7 @@ class UserMailLogFactory extends Factory { return $this->state(fn (array $attributes) => [ 'email_type' => DripEmailType::ImportHelp, + 'email_identifier' => DripEmailType::ImportHelp->value, ]); } @@ -57,6 +64,7 @@ class UserMailLogFactory extends Factory { return $this->state(fn (array $attributes) => [ 'email_type' => DripEmailType::Feedback, + 'email_identifier' => DripEmailType::Feedback->value, ]); } @@ -64,6 +72,7 @@ class UserMailLogFactory extends Factory { return $this->state(fn (array $attributes) => [ 'email_type' => DripEmailType::SubscriptionCancelled, + 'email_identifier' => DripEmailType::SubscriptionCancelled->value, ]); } } diff --git a/database/migrations/2026_01_08_192757_add_email_identifier_to_user_mail_logs.php b/database/migrations/2026_01_08_192757_add_email_identifier_to_user_mail_logs.php new file mode 100644 index 00000000..3a82fb58 --- /dev/null +++ b/database/migrations/2026_01_08_192757_add_email_identifier_to_user_mail_logs.php @@ -0,0 +1,28 @@ +string('email_identifier')->nullable()->after('email_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_mail_logs', function (Blueprint $table) { + $table->dropColumn('email_identifier'); + }); + } +}; diff --git a/database/migrations/2026_01_08_204223_update_user_mail_logs_unique_constraint.php b/database/migrations/2026_01_08_204223_update_user_mail_logs_unique_constraint.php new file mode 100644 index 00000000..22e58427 --- /dev/null +++ b/database/migrations/2026_01_08_204223_update_user_mail_logs_unique_constraint.php @@ -0,0 +1,79 @@ +whereNull('email_identifier') + ->update([ + 'email_identifier' => DB::raw('email_type'), + ]); + + Schema::table('user_mail_logs', function (Blueprint $table) { + // Make email_identifier non-nullable + $table->string('email_identifier')->nullable(false)->change(); + }); + + // Drop the foreign key constraint temporarily + Schema::table('user_mail_logs', function (Blueprint $table) { + $table->dropForeign('user_mail_logs_user_id_foreign'); + }); + + // Now drop the old unique constraint + Schema::table('user_mail_logs', function (Blueprint $table) { + $table->dropUnique('user_mail_logs_user_id_email_type_unique'); + }); + + // Add new unique constraint that includes email_identifier + Schema::table('user_mail_logs', function (Blueprint $table) { + $table->unique(['user_id', 'email_type', 'email_identifier'], 'user_mail_logs_unique'); + }); + + // Re-add the foreign key constraint + Schema::table('user_mail_logs', function (Blueprint $table) { + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Drop the foreign key constraint + Schema::table('user_mail_logs', function (Blueprint $table) { + $table->dropForeign(['user_id']); + }); + + // Drop the new constraint + Schema::table('user_mail_logs', function (Blueprint $table) { + $table->dropUnique('user_mail_logs_unique'); + }); + + // Re-add the old constraint + Schema::table('user_mail_logs', function (Blueprint $table) { + $table->unique(['user_id', 'email_type'], 'user_mail_logs_user_id_email_type_unique'); + }); + + // Re-add the foreign key + Schema::table('user_mail_logs', function (Blueprint $table) { + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + + // Make email_identifier nullable again + Schema::table('user_mail_logs', function (Blueprint $table) { + $table->string('email_identifier')->nullable()->change(); + }); + } +}; diff --git a/resources/views/mail/updates/.gitkeep b/resources/views/mail/updates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/resources/views/mail/updates/README.md b/resources/views/mail/updates/README.md new file mode 100644 index 00000000..8a62c8c2 --- /dev/null +++ b/resources/views/mail/updates/README.md @@ -0,0 +1,129 @@ +# Update Email Templates + +This directory contains markdown templates for sending update emails to all users. + +## How to Use + +### 1. Create Your Email Template + +Create a new Blade file in this directory with your update message: + +```blade + + +# What's New in January 2026 + +Hi {{ $user->name }}, + +We've shipped some exciting updates this month: + +- **Feature A**: Description of new feature +- **Feature B**: Description of improvement +- **Bug Fix C**: Description of fix + + +Check it out + + +Thanks for using Whisper Money! + +Victor, Founder of Whisper Money + +``` + +### 2. Available Variables + +All templates have access to: +- `$user` - The User model instance with all properties (name, email, etc.) + +### 3. Send the Email + +Once you've created your template and deployed it to production: + +```bash +# Basic usage +php artisan update-email:send jan-2026-updates jan-2026-updates + +# With custom subject +php artisan update-email:send jan-2026-updates jan-2026-updates --subject="Exciting January Updates!" + +# Exclude demo account +php artisan update-email:send jan-2026-updates jan-2026-updates --exclude-demo + +# Skip confirmation prompt (for scripts/automation) +php artisan update-email:send jan-2026-updates jan-2026-updates --force +``` + +### 4. Command Arguments + +- `view`: The name of your template file (without .blade.php extension) +- `identifier`: A unique tracking identifier to prevent duplicate sends + +### 5. How Tracking Works + +Each update email is tracked using: +- Email type: "Update" (stored in DripEmailType enum) +- Email identifier: Your custom identifier (e.g., "jan-2026-updates") + +This means: +- ✅ Running the same command twice won't send duplicates +- ✅ Users who already received this update will be skipped +- ✅ You can send different update emails (with different identifiers) to the same users + +### 6. Email Components + +Use Laravel's built-in mail components: + +```blade + + +Click Here + + + + +Important information here + + + + +| Header 1 | Header 2 | +|----------|----------| +| Cell 1 | Cell 2 | + +``` + +### 7. Example Workflow + +```bash +# 1. Create your template locally +vim resources/views/mail/updates/feb-2026-updates.blade.php + +# 2. Test locally (optional - create test user first) +php artisan update-email:send feb-2026-updates test-feb-2026 + +# 3. Commit and push +git add resources/views/mail/updates/feb-2026-updates.blade.php +git commit -m "Add February 2026 update email" +git push + +# 4. Deploy to production +# ... your deployment process ... + +# 5. Send on production +php artisan update-email:send feb-2026-updates feb-2026-updates +``` + +## Best Practices + +1. **Use descriptive identifiers**: `jan-2026-product-updates` is better than `update1` +2. **Test locally first**: Send to a test user before production +3. **Version control everything**: All templates should be committed to git +4. **Keep it concise**: Users appreciate brief, scannable updates +5. **Include CTAs**: Use buttons to drive users back to the app +6. **Consistent voice**: Maintain the personal, privacy-focused tone + +## Learn More + +- [Laravel Markdown Mailable Docs](https://laravel.com/docs/12.x/mail#markdown-mailables) +- [Laravel Mail Components](https://laravel.com/docs/12.x/mail#markdown-components) diff --git a/resources/views/mail/updates/first-update-jan-2026.blade.php b/resources/views/mail/updates/first-update-jan-2026.blade.php new file mode 100644 index 00000000..0a81f903 --- /dev/null +++ b/resources/views/mail/updates/first-update-jan-2026.blade.php @@ -0,0 +1,43 @@ + +# A Personal Thank You & What's Next for Whisper Money + +Hi {{ $user->name }}, + +It's Víctor here, the solo developer behind Whisper Money. I'm writing from my personal email because I wanted to reach out and share some exciting news with you. + +## We're Growing! 🎉 + +Since launching, Whisper Money has already reached [**+240 stars on GitHub**](https://github.com/whisper-money/whisper-money) – which is honestly massive for a privacy-first finance app! + +Even more meaningful: we now have our first paying users. This means I need to keep improving the product to earn your trust every day. + +## I Need Your Help + +Whisper Money is a small project built by one person (me!), but it can become something amazing with your input. Here's how you can help shape the future: + + +**Join our Discord community** – Share ideas, report bugs, or just chat about privacy-first finance. [Join our comminity](https://discord.gg/9UQWZECDDv). + +**Check our Roadmap** – See what's coming next and vote on features you care about: +[whisper-money.canny.io](https://whisper-money.canny.io/) + +**Request Features** – Have an idea? I want to hear it: +[whisper-money.canny.io/feature-requests](https://whisper-money.canny.io/feature-requests) + + +## Why This Matters + +Building a privacy-first alternative to mainstream finance apps is a big challenge. Every star on GitHub, every feature request, every Discord message helps me understand what you need. And because this is a solo project, your feedback directly shapes what gets built next. + +**Feel free to reply to this email** – I read every message personally. + +Thank you for trusting Whisper Money with your financial data. Your privacy is my priority, always. + + +Join the Discord Comminity + + +Víctor Falcón Ruíz

+Founder & Solo Developer, Whisper Money + +
diff --git a/tests/Feature/Console/SendUpdateEmailCommandTest.php b/tests/Feature/Console/SendUpdateEmailCommandTest.php new file mode 100644 index 00000000..4c219176 --- /dev/null +++ b/tests/Feature/Console/SendUpdateEmailCommandTest.php @@ -0,0 +1,206 @@ + +# Test Update + +Hello {{ $user->name }}, + +This is a test update email. + +Thanks, +Victor + +BLADE; + + File::put( + resource_path('views/mail/updates/test-update.blade.php'), + $testViewContent + ); +}); + +afterEach(function () { + $testViewPath = resource_path('views/mail/updates/test-update.blade.php'); + if (File::exists($testViewPath)) { + File::delete($testViewPath); + } +}); + +test('command dispatches jobs for all users', function () { + $users = User::factory()->count(3)->create(); + + artisan('email:update', [ + 'view' => 'test-update', + 'identifier' => 'test-2026', + '--force' => true, + ])->assertSuccessful(); + + Queue::assertPushed(SendUpdateEmailJob::class, 3); + + foreach ($users as $user) { + Queue::assertPushed(SendUpdateEmailJob::class, function ($job) use ($user) { + return $job->user->id === $user->id + && $job->viewName === 'test-update' + && str_starts_with($job->emailIdentifier, 'test-2026_force_') + && $job->subject === 'Update from Whisper Money'; + }); + } +}); + +test('command excludes demo account when flag is set', function () { + $regularUser = User::factory()->create(); + $demoUser = User::factory()->create(['email' => config('app.demo.email')]); + + artisan('email:update', [ + 'view' => 'test-update', + 'identifier' => 'test-2026', + '--exclude-demo' => true, + '--force' => true, + ])->assertSuccessful(); + + Queue::assertPushed(SendUpdateEmailJob::class, function ($job) use ($regularUser) { + return $job->user->id === $regularUser->id; + }); + + Queue::assertNotPushed(SendUpdateEmailJob::class, function ($job) use ($demoUser) { + return $job->user->id === $demoUser->id; + }); + + Queue::assertCount(1); +}); + +test('command fails when view does not exist', function () { + User::factory()->create(); + + artisan('email:update', [ + 'view' => 'non-existent-view', + 'identifier' => 'test-2026', + '--force' => true, + ])->assertFailed(); + + Queue::assertNothingPushed(); +}); + +test('command uses custom subject when provided', function () { + User::factory()->create(); + + artisan('email:update', [ + 'view' => 'test-update', + 'identifier' => 'test-2026', + '--subject' => 'Custom Subject Here', + '--force' => true, + ])->assertSuccessful(); + + Queue::assertPushed(SendUpdateEmailJob::class, function ($job) { + return $job->subject === 'Custom Subject Here'; + }); +}); + +test('job is dispatched to emails queue', function () { + User::factory()->create(); + + artisan('email:update', [ + 'view' => 'test-update', + 'identifier' => 'test-2026', + '--force' => true, + ])->assertSuccessful(); + + Queue::assertPushedOn('emails', SendUpdateEmailJob::class); +}); + +test('command handles empty user database gracefully', function () { + artisan('email:update', [ + 'view' => 'test-update', + 'identifier' => 'test-2026', + '--force' => true, + ])->assertSuccessful(); + + Queue::assertNothingPushed(); +}); + +test('job skips users who already received the update', function () { + Queue::fake([]); + + $user = User::factory()->create(); + + UserMailLog::create([ + 'user_id' => $user->id, + 'email_type' => DripEmailType::Update, + 'email_identifier' => 'test-2026', + 'sent_at' => now(), + ]); + + $job = new SendUpdateEmailJob($user, 'test-update', 'test-2026'); + $job->handle(); + + expect(UserMailLog::where('user_id', $user->id)->count())->toBe(1); +}); + +test('job creates mail log entry after sending', function () { + Queue::fake([]); + + $user = User::factory()->create(); + + expect(UserMailLog::where('user_id', $user->id)->exists())->toBeFalse(); + + $job = new SendUpdateEmailJob($user, 'test-update', 'test-2026'); + $job->handle(); + + expect(UserMailLog::where('user_id', $user->id) + ->where('email_type', DripEmailType::Update) + ->where('email_identifier', 'test-2026') + ->exists())->toBeTrue(); +}); + +test('job sends email with correct view and user data', function () { + Queue::fake([]); + + $user = User::factory()->create(['name' => 'John Doe']); + + $job = new SendUpdateEmailJob($user, 'test-update', 'test-2026', 'Custom Subject'); + $job->handle(); + + expect(UserMailLog::where('user_id', $user->id)->exists())->toBeTrue(); +}); + +test('command requires confirmation by default', function () { + User::factory()->create(); + + artisan('email:update', [ + 'view' => 'test-update', + 'identifier' => 'test-2026', + ])->expectsConfirmation("About to send 'test-2026' email to 1 user(s). Continue?", 'no') + ->assertSuccessful(); + + Queue::assertNothingPushed(); +}); + +test('command skips confirmation with force flag', function () { + User::factory()->create(); + + artisan('email:update', [ + 'view' => 'test-update', + 'identifier' => 'test-2026', + '--force' => true, + ])->doesntExpectOutput('Continue?') + ->assertSuccessful(); + + Queue::assertPushed(SendUpdateEmailJob::class, 1); +});