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:
parent
0646b380ce
commit
683b3f32a7
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,4 +10,5 @@ enum DripEmailType: string
|
|||
case ImportHelp = 'import_help';
|
||||
case Feedback = 'feedback';
|
||||
case SubscriptionCancelled = 'subscription_cancelled';
|
||||
case Update = 'update';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue