feat: Send custom emails to users (#52)

## 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
<x-mail::message>
# What's New in January 2026

Hi {{ $user->name }},

Your update content here...

<x-mail::button :url="'https://discord.gg/9UQWZECDDv'">
Join the Discord Community
</x-mail::button>

Víctor Falcón Ruíz
Founder & Solo Developer, Whisper Money
</x-mail::message>
```

### 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 <view> [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
This commit is contained in:
Víctor Falcón 2026-01-09 09:33:19 +01:00 committed by GitHub
parent 0646b380ce
commit 683b3f32a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 732 additions and 1 deletions

View File

@ -0,0 +1,94 @@
<?php
namespace App\Console\Commands;
use App\Jobs\SendUpdateEmailJob;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class SendUpdateEmailCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'email:update
{view : The view name (e.g., "jan-2026-updates")}
{identifier? : The tracking identifier (defaults to view name)}
{--subject= : Custom email subject (default: "Update from Whisper Money")}
{--exclude-demo : Exclude the demo account}
{--force : Skip confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Send update emails to all users using a markdown view template';
/**
* Execute the console command.
*/
public function handle(): int
{
$viewName = $this->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;
}
}

View File

@ -10,4 +10,5 @@ enum DripEmailType: string
case ImportHelp = 'import_help';
case Feedback = 'feedback';
case SubscriptionCancelled = 'subscription_cancelled';
case Update = 'update';
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,68 @@
<?php
namespace App\Jobs;
use App\Enums\DripEmailType;
use App\Mail\UpdateEmail;
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 SendUpdateEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, 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,
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();
}
}

66
app/Mail/UpdateEmail.php Normal file
View File

@ -0,0 +1,66 @@
<?php
namespace App\Mail;
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 UpdateEmail 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,
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<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

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

View File

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

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('user_mail_logs', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// First, populate email_identifier for existing records
// For drip emails, use the email_type as the identifier
DB::table('user_mail_logs')
->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();
});
}
};

View File

View File

@ -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
<!-- resources/views/mail/updates/jan-2026-updates.blade.php -->
<x-mail::message>
# 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
<x-mail::button :url="config('app.url')">
Check it out
</x-mail::button>
Thanks for using Whisper Money!
Victor, Founder of Whisper Money
</x-mail::message>
```
### 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
<!-- Button -->
<x-mail::button :url="$url">
Click Here
</x-mail::button>
<!-- Panel -->
<x-mail::panel>
Important information here
</x-mail::panel>
<!-- Table -->
<x-mail::table>
| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
</x-mail::table>
```
### 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)

View File

@ -0,0 +1,43 @@
<x-mail::message>
# 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:
<x-mail::panel>
**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)
</x-mail::panel>
## 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.
<x-mail::button :url="'https://discord.gg/9UQWZECDDv'">
Join the Discord Comminity
</x-mail::button>
Víctor Falcón Ruíz</br></br>
Founder & Solo Developer, Whisper Money
</x-mail::message>

View File

@ -0,0 +1,206 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\SendUpdateEmailJob;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Queue;
use function Pest\Laravel\artisan;
beforeEach(function () {
Queue::fake();
$viewPath = resource_path('views/mail/updates');
if (! File::exists($viewPath)) {
File::makeDirectory($viewPath, 0755, true);
}
$testViewContent = <<<'BLADE'
<x-mail::message>
# Test Update
Hello {{ $user->name }},
This is a test update email.
Thanks,
Victor
</x-mail::message>
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);
});