feat: Implement drip email campaign system (#35)

## Summary

- Implement event-driven drip email campaign system for new user signups
- Add 5 targeted emails based on user journey (welcome, onboarding
reminder, promo code, import help, feedback)
- Configure Laravel Horizon with Redis queue for reliable job processing
- Track sent emails in `user_mail_logs` table to prevent duplicates

## Email Schedule

| Email | Timing | Condition |
|-------|--------|-----------|
| Welcome | 30 min after signup | All users |
| Onboarding Reminder | 1 day after signup | Not onboarded |
| Promo Code (FOUNDER) | 1 day after signup | Onboarded + has
transactions + not subscribed |
| Import Help | 1 day after signup | Onboarded + no transactions + not
subscribed |
| Feedback | 5 days after signup | Not subscribed |

## Architecture

```
User Registers → ScheduleDripEmailsListener
    ├─> SendWelcomeEmailJob (30 min delay)
    ├─> SendOnboardingReminderEmailJob (1 day delay)
    ├─> SendPromoCodeEmailJob (1 day delay)
    ├─> SendImportHelpEmailJob (1 day delay)
    └─> SendFeedbackEmailJob (5 days delay)
```

Each job checks conditions at execution time and either sends the email
+ logs it, or silently completes.

## Test plan

- [x] All 23 drip email tests pass
- [x] Migration creates `user_mail_logs` table
- [x] Jobs dispatch with correct delays
- [x] Emails only sent when conditions are met
- [x] Duplicate emails prevented via UserMailLog check
- [ ] Verify Horizon processes jobs in production
This commit is contained in:
Víctor Falcón 2025-12-17 08:34:17 +01:00 committed by Víctor Falcón
parent 3c22453fc6
commit 46c5b13739
57 changed files with 2111 additions and 27 deletions

View File

@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(bun run format)",
"Bash(bun run lint)"
]
},
"enabledMcpjsonServers": [
"laravel-boost"
],
"enableAllProjectMcpServers": true
}

View File

@ -47,16 +47,14 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_FROM_ADDRESS="hi@whisper.money"
MAIL_FROM_NAME="Whisper Money"
# Resend Email Service (set MAIL_MAILER=resend to use)
# Resend Email Service (set MAIL_MAILER=resend to use in production)
RESEND_API_KEY=
AWS_ACCESS_KEY_ID=
@ -79,3 +77,7 @@ STRIPE_WEBHOOK_SECRET=
SUBSCRIPTIONS_ENABLED=false
STRIPE_PRO_MONTHLY_PRICE_ID=
STRIPE_PRO_YEARLY_PRICE_ID=
# Horizon Basic Auth (optional - if not set, Horizon will be accessible without authentication)
HORIZON_BASIC_AUTH_USERNAME=
HORIZON_BASIC_AUTH_PASSWORD=

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ yarn-error.log
/.zed
.php-cs-fixer.cache
/traefik/certs/*.pem
.claude/settings.local.json

View File

@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use App\Mail\Drip\FeedbackEmail;
use App\Mail\Drip\ImportHelpEmail;
use App\Mail\Drip\OnboardingReminderEmail;
use App\Mail\Drip\PromoCodeEmail;
use App\Mail\Drip\WelcomeEmail;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendTestEmails extends Command
{
protected $signature = 'emails:test {email : The email address to send all test emails to}';
protected $description = 'Send all application emails to a specific email address for testing';
public function handle(): int
{
$email = $this->argument('email');
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error("Invalid email address: {$email}");
return self::FAILURE;
}
$this->info("Sending all test emails to: {$email}");
$this->newLine();
$user = User::firstOrCreate(
['email' => $email],
[
'name' => 'Test User',
'password' => bcrypt('password'),
]
);
$emails = [
'Welcome Email' => new WelcomeEmail($user),
'Feedback Email' => new FeedbackEmail($user),
'Import Help Email' => new ImportHelpEmail($user),
'Onboarding Reminder Email' => new OnboardingReminderEmail($user),
'Promo Code Email' => new PromoCodeEmail($user),
];
foreach ($emails as $name => $mailable) {
try {
Mail::to($email)->send($mailable);
$this->info("✓ Sent: {$name}");
} catch (\Exception $e) {
$this->error("✗ Failed to send {$name}: {$e->getMessage()}");
}
}
$this->newLine();
$this->info('All emails sent successfully!');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum DripEmailType: string
{
case Welcome = 'welcome';
case OnboardingReminder = 'onboarding_reminder';
case PromoCode = 'promo_code';
case ImportHelp = 'import_help';
case Feedback = 'feedback';
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class HorizonBasicAuth
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$username = config('horizon.basic_auth_username');
$password = config('horizon.basic_auth_password');
if (empty($username) || empty($password)) {
return $next($request);
}
$authUser = $request->getUser();
$authPassword = $request->getPassword();
if ($authUser !== $username || $authPassword !== $password) {
return response('Unauthorized', 401, [
'WWW-Authenticate' => 'Basic realm="Horizon"',
]);
}
return $next($request);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\FeedbackEmail;
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 SendFeedbackEmailJob 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::Feedback)) {
return;
}
if ($this->user->hasProPlan()) {
return;
}
Mail::to($this->user)->send(new FeedbackEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::Feedback,
'sent_at' => now(),
]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\ImportHelpEmail;
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 SendImportHelpEmailJob 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::ImportHelp)) {
return;
}
if (! $this->user->isOnboarded()) {
return;
}
if ($this->user->transactions()->exists()) {
return;
}
if ($this->user->hasProPlan()) {
return;
}
Mail::to($this->user)->send(new ImportHelpEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::ImportHelp,
'sent_at' => now(),
]);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\OnboardingReminderEmail;
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 SendOnboardingReminderEmailJob 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::OnboardingReminder)) {
return;
}
if ($this->user->isOnboarded()) {
return;
}
Mail::to($this->user)->send(new OnboardingReminderEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::OnboardingReminder,
'sent_at' => now(),
]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\PromoCodeEmail;
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 SendPromoCodeEmailJob 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::PromoCode)) {
return;
}
if (! $this->user->isOnboarded()) {
return;
}
if (! $this->user->transactions()->exists()) {
return;
}
if ($this->user->hasProPlan()) {
return;
}
Mail::to($this->user)->send(new PromoCodeEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::PromoCode,
'sent_at' => now(),
]);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Mail\Drip\WelcomeEmail;
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 SendWelcomeEmailJob 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::Welcome)) {
return;
}
Mail::to($this->user)->send(new WelcomeEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::Welcome,
'sent_at' => now(),
]);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Listeners;
use App\Jobs\Drip\SendFeedbackEmailJob;
use App\Jobs\Drip\SendImportHelpEmailJob;
use App\Jobs\Drip\SendOnboardingReminderEmailJob;
use App\Jobs\Drip\SendPromoCodeEmailJob;
use App\Jobs\Drip\SendWelcomeEmailJob;
use Illuminate\Auth\Events\Registered;
class ScheduleDripEmailsListener
{
public function handle(Registered $event): void
{
$user = $event->user;
SendWelcomeEmailJob::dispatch($user)->delay(now()->addMinutes(30));
SendOnboardingReminderEmailJob::dispatch($user)->delay(now()->addDay());
SendPromoCodeEmailJob::dispatch($user)->delay(now()->addDay());
SendImportHelpEmailJob::dispatch($user)->delay(now()->addDay());
SendFeedbackEmailJob::dispatch($user)->delay(now()->addDays(5));
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class FeedbackEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $user) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "How's Your Experience So Far?",
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.feedback',
with: [
'userName' => $this->user->name,
],
);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ImportHelpEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $user) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "Let's Import Your Transactions",
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.import-help',
with: [
'userName' => $this->user->name,
],
);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class OnboardingReminderEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $user) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Need Help Getting Started?',
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.onboarding-reminder',
with: [
'userName' => $this->user->name,
],
);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PromoCodeEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $user) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your Founder Discount - First Month for $1',
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.promo-code',
with: [
'userName' => $this->user->name,
'promoCode' => 'FOUNDER',
],
);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class WelcomeEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $user) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Welcome to Whisper Money - Your Privacy-First Finance App',
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.welcome',
with: [
'userName' => $this->user->name,
],
);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\DripEmailType;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -92,6 +93,16 @@ class User extends Authenticatable
return $this->hasMany(Label::class);
}
public function mailLogs(): HasMany
{
return $this->hasMany(UserMailLog::class);
}
public function hasReceivedEmail(DripEmailType $type): bool
{
return $this->mailLogs()->where('email_type', $type)->exists();
}
public function hasProPlan(): bool
{
if (! config('subscriptions.enabled')) {

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use App\Enums\DripEmailType;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserMailLog extends Model
{
use HasFactory, HasUuids;
protected $fillable = [
'user_id',
'email_type',
'sent_at',
];
protected function casts(): array
{
return [
'email_type' => DripEmailType::class,
'sent_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -3,6 +3,9 @@
namespace App\Providers;
use App\Http\Responses\RegisterResponse;
use App\Listeners\ScheduleDripEmailsListener;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
@ -21,6 +24,6 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Event::listen(Registered::class, ScheduleDripEmailsListener::class);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Providers;
use App\Http\Middleware\HorizonBasicAuth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$username = config('horizon.basic_auth_username');
$password = config('horizon.basic_auth_password');
if (! empty($username) && ! empty($password)) {
$middleware = config('horizon.middleware', ['web']);
$middleware[] = HorizonBasicAuth::class;
Config::set('horizon.middleware', $middleware);
}
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return in_array(optional($user)->email, [
//
]);
});
}
}

View File

@ -3,4 +3,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
];

View File

@ -62,6 +62,13 @@ services:
- ping
retries: 3
timeout: 5s
mailhog:
image: 'mailhog/mailhog:latest'
ports:
- '${FORWARD_MAILHOG_PORT:-1025}:1025'
- '${FORWARD_MAILHOG_DASHBOARD_PORT:-8025}:8025'
networks:
- sail
networks:
sail:
driver: bridge

View File

@ -14,6 +14,7 @@
"laravel/cashier": "^16.1",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/horizon": "^5.40",
"laravel/tinker": "^2.10.1",
"laravel/wayfinder": "^0.1.9",
"resend/resend-php": "^1.1"

81
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "40779ce6890f5022e5e7c7c569bd328c",
"content-hash": "c0e2b34e60716f64e9ffb87ec573f103",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -1598,6 +1598,85 @@
},
"time": "2025-11-04T15:39:33+00:00"
},
{
"name": "laravel/horizon",
"version": "v5.40.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/horizon.git",
"reference": "005e5638478db9e25f7ae5cfb30c56846fbad793"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/horizon/zipball/005e5638478db9e25f7ae5cfb30c56846fbad793",
"reference": "005e5638478db9e25f7ae5cfb30c56846fbad793",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-pcntl": "*",
"ext-posix": "*",
"illuminate/contracts": "^9.21|^10.0|^11.0|^12.0",
"illuminate/queue": "^9.21|^10.0|^11.0|^12.0",
"illuminate/support": "^9.21|^10.0|^11.0|^12.0",
"nesbot/carbon": "^2.17|^3.0",
"php": "^8.0",
"ramsey/uuid": "^4.0",
"symfony/console": "^6.0|^7.0",
"symfony/error-handler": "^6.0|^7.0",
"symfony/polyfill-php83": "^1.28",
"symfony/process": "^6.0|^7.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^7.55|^8.36|^9.15|^10.8",
"phpstan/phpstan": "^1.10|^2.0",
"predis/predis": "^1.1|^2.0|^3.0"
},
"suggest": {
"ext-redis": "Required to use the Redis PHP driver.",
"predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Horizon": "Laravel\\Horizon\\Horizon"
},
"providers": [
"Laravel\\Horizon\\HorizonServiceProvider"
]
},
"branch-alias": {
"dev-master": "6.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Horizon\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Dashboard and code-driven configuration for Laravel queues.",
"keywords": [
"laravel",
"queue"
],
"support": {
"issues": "https://github.com/laravel/horizon/issues",
"source": "https://github.com/laravel/horizon/tree/v5.40.2"
},
"time": "2025-11-28T20:12:12+00:00"
},
{
"name": "laravel/prompts",
"version": "v0.3.7",

262
config/horizon.php Normal file
View File

@ -0,0 +1,262 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Name
|--------------------------------------------------------------------------
|
| This name appears in notifications and in the Horizon UI. Unique names
| can be useful while running multiple instances of Horizon within an
| application, allowing you to identify the Horizon you're viewing.
|
*/
'name' => env('HORIZON_NAME'),
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Horizon Basic Auth
|--------------------------------------------------------------------------
|
| These credentials are used for HTTP Basic Authentication on the Horizon
| dashboard. If these are not set, empty, or null, the page will be
| accessible without authentication.
|
*/
'basic_auth_username' => env('HORIZON_BASIC_AUTH_USERNAME'),
'basic_auth_password' => env('HORIZON_BASIC_AUTH_PASSWORD'),
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
'silenced_tags' => [
// 'notifications',
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 64,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
'defaults' => [
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'nice' => 0,
],
'supervisor-emails' => [
'connection' => 'redis',
'queue' => ['emails'],
'balance' => 'simple',
'maxProcesses' => 2,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 3,
'timeout' => 60,
'nice' => 0,
],
],
'environments' => [
'production' => [
'supervisor-default' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
'supervisor-emails' => [
'maxProcesses' => 3,
],
],
'local' => [
'supervisor-default' => [
'maxProcesses' => 3,
],
'supervisor-emails' => [
'maxProcesses' => 2,
],
],
],
];

View File

@ -0,0 +1,62 @@
<?php
namespace Database\Factories;
use App\Enums\DripEmailType;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserMailLog>
*/
class UserMailLogFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'email_type' => fake()->randomElement(DripEmailType::cases()),
'sent_at' => now(),
];
}
public function welcome(): static
{
return $this->state(fn (array $attributes) => [
'email_type' => DripEmailType::Welcome,
]);
}
public function onboardingReminder(): static
{
return $this->state(fn (array $attributes) => [
'email_type' => DripEmailType::OnboardingReminder,
]);
}
public function promoCode(): static
{
return $this->state(fn (array $attributes) => [
'email_type' => DripEmailType::PromoCode,
]);
}
public function importHelp(): static
{
return $this->state(fn (array $attributes) => [
'email_type' => DripEmailType::ImportHelp,
]);
}
public function feedback(): static
{
return $this->state(fn (array $attributes) => [
'email_type' => DripEmailType::Feedback,
]);
}
}

View File

@ -0,0 +1,33 @@
<?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::create('user_mail_logs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->string('email_type', 50);
$table->timestamp('sent_at');
$table->timestamps();
$table->unique(['user_id', 'email_type']);
$table->index('email_type');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_mail_logs');
}
};

View File

@ -54,11 +54,11 @@ autorestart=true
stderr_logfile=/var/log/worker-inertia-ssr.log
stdout_logfile=/var/log/worker-inertia-ssr.log
# [program:horizon]
# process_name=%(program_name)s
# command=php /app/artisan horizon
# autostart=true
# autorestart=true
# redirect_stderr=true
# stdout_logfile=/var/log/horizon.log
# stopwaitsecs=3600
[program:horizon]
process_name=%(program_name)s
command=php /app/artisan horizon
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/horizon.log
stopwaitsecs=3600

View File

@ -0,0 +1,25 @@
<x-mail::message>
# How's it going, {{ $userName }}?
Hi! It's Victor here, the founder of Whisper Money. You've been using the app for a few days now, and I'd love to hear how it's working for you.
## A Few Quick Questions
- Is the app meeting your expectations?
- Is there anything confusing or frustrating?
- What features would make Whisper Money more useful for you?
As a solo founder, your feedback directly shapes what I build next. I personally read every response and take your suggestions seriously. When you subscribe, you're not just supporting some big corporation - you're helping me continue building something I'm passionate about.
## Help Me Improve
Whether it's a bug you've found, a feature you'd love to see, or just general thoughts - I want to hear it all. This is my project, and I care deeply about making it work well for you.
Just reply to this email with your feedback. No surveys, no forms, just a direct conversation with me.
Thanks for being part of Whisper Money. It really means a lot.
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -0,0 +1,32 @@
<x-mail::message>
# Need help importing your transactions, {{ $userName }}?
Hi! It's Victor, the founder of Whisper Money. I noticed you've completed your setup but haven't imported any transactions yet. Let me help you get started!
## How to Import Your Transactions
**Step 1: Export from your bank**
Log into your bank's website and look for "Export" or "Download transactions". Choose CSV format if available.
**Step 2: Upload to Whisper Money**
Go to your dashboard and click "Import Transactions". Select your CSV file and I'll map the columns automatically.
**Step 3: Review and confirm**
Check that everything looks correct and click "Import". Your transactions will be encrypted and stored securely.
## Prefer to Start Fresh?
You can also manually add transactions and account balances. Some users prefer to start tracking from today rather than importing history - that's totally fine! Do whatever works best for you.
<x-mail::button :url="config('app.url') . '/dashboard'">
Go to Dashboard
</x-mail::button>
If you're having trouble with the import or need help with your specific bank's format, just reply to this email. I personally handle support and I'm happy to help you figure it out.
Thanks for giving Whisper Money a try!
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -0,0 +1,28 @@
<x-mail::message>
# Hey {{ $userName }}, is everything okay?
Hi! It's Victor, the founder of Whisper Money. I noticed you signed up but haven't completed your setup yet. I wanted to check in personally and see if something went wrong or if you need any help.
I know setting up a new app can be overwhelming, and I want to make sure you have everything you need to get started.
## Common Questions
**Is the encryption confusing?**
No worries! Your encryption key is automatically generated and stored securely on your device. You don't need to remember anything - I've made it as simple as possible.
**Not sure how to import transactions?**
I support CSV imports from most banks. Just export your transactions and upload them - it takes less than a minute. If your bank format is different, just let me know and I can help.
**Something not working?**
Just reply to this email and let me know what's happening. I personally read every response and will help you get started. This is my project, so I care about making sure it works for you.
<x-mail::button :url="config('app.url') . '/onboarding'">
Continue Setup
</x-mail::button>
Looking forward to hearing from you! And if you decide Whisper Money isn't for you, that's totally fine - but I'd love to know why so I can improve.
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -0,0 +1,32 @@
<x-mail::message>
# Welcome aboard, {{ $userName }}!
Hi! It's Victor, the founder of Whisper Money. I see you've already started importing your transactions - that's awesome! You're well on your way to taking control of your finances while keeping your data private.
## A Special Offer for You
As one of our early users, I want to offer you a special founder's discount. When you subscribe, you're not just getting a great app - you're directly supporting me as I continue building Whisper Money. Every subscription helps me keep the lights on and build features you actually want.
<x-mail::panel>
Use code **{{ $promoCode }}** to get your first month for just **$1**
</x-mail::panel>
This gives you full access to all Whisper Money features:
- Unlimited transaction imports
- Automated categorization rules
- Multiple account tracking
- End-to-end encrypted storage
<x-mail::button :url="config('app.url') . '/subscribe'">
Claim Your Discount
</x-mail::button>
This code won't last forever, but more importantly, your support means the world to me. As a solo founder, every subscriber helps me continue building something I'm passionate about.
Thanks for being part of this journey with me!
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -0,0 +1,31 @@
<x-mail::message>
# Welcome to Whisper Money, {{ $userName }}!
Hi! I'm Victor, the founder of Whisper Money. I wanted to personally welcome you and thank you for joining us.
When I started building Whisper Money, I was frustrated with finance apps that wanted to mine my data or use AI to analyze my spending. I just wanted a simple, private way to track my finances - and I figured others might too.
## Your Data is Truly Private
I built Whisper Money with **end-to-end encryption** because I believe your financial data should be yours alone:
- Your financial data is encrypted with a key that only you control
- I cannot read your transactions, balances, or any personal information (and I don't want to!)
- Your encryption key never leaves your device
## No AI, No Data Mining
I don't use AI to analyze your spending or sell insights about your habits. Your financial data stays between you and your spreadsheet-loving self.
What's more, even if I wanted to use it, I couldn't, since you're the only one who has the key to read the data.
<div style="height: 40px"></div>
If you have any questions or run into any issues, **just reply to this email**. I personally read every message and I'm here to help!
Thanks for giving Whisper Money a try. It means a lot to me.
Best,<br>
Víctor F,<br>
Founder of Whisper Money
</x-mail::message>

View File

@ -0,0 +1,24 @@
@props([
'url',
'color' => 'primary',
'align' => 'center',
])
<table class="action" align="{{ $align }}" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<a href="{{ $url }}" class="button button-{{ $color }}" target="_blank" rel="noopener">{!! $slot !!}</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,11 @@
<tr>
<td>
<table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>

View File

@ -0,0 +1,23 @@
@props(['url'])
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
@if (trim($slot) === 'Laravel')
<svg class="logo" width="75" height="75" viewBox="0 0 24 24" fill="none" s<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bird-icon lucide-bird"><path d="M16 7h.01"/><path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20"/><path d="m20 7 2 .5-2 .5"/><path d="M10 18v3"/><path d="M14 17.75V21"/><path d="M7 18a6 6 0 0 0 3.84-10.61"/></svg>troke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #18181b;">
<path d="M16 7h.01"/>
<path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v10.9a2 2 0 0 0 2.4 2.1"/>
<path d="M7 15h1"/>
<path d="M7 12h1"/>
<path d="M7 9h2"/>
<path d="M11 12h6"/>
<path d="M11 15h6"/>
<path d="M11 9h6"/>
</svg>
@elseif (trim($slot) === config('app.name'))
<svg class="logo" xmlns="http://www.w3.org/2000/svg" width="75" height="75" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bird-icon lucide-bird"><path d="M16 7h.01"/><path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20"/><path d="m20 7 2 .5-2 .5"/><path d="M10 18v3"/><path d="M14 17.75V21"/><path d="M7 18a6 6 0 0 0 3.84-10.61"/></svg>
@else
{!! $slot !!}
@endif
</a>
</td>
</tr>

View File

@ -0,0 +1,58 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>{{ config('app.name') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<style>
@media only screen and (max-width: 600px) {
.inner-body {
width: 100% !important;
}
.footer {
width: 100% !important;
}
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
}
}
</style>
{!! $head ?? '' !!}
</head>
<body>
<table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
{!! $header ?? '' !!}
<!-- Email Body -->
<tr>
<td class="body" width="100%" cellpadding="0" cellspacing="0" style="border: hidden !important;">
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
{!! Illuminate\Mail\Markdown::parse($slot) !!}
{!! $subcopy ?? '' !!}
</td>
</tr>
</table>
</td>
</tr>
{!! $footer ?? '' !!}
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,27 @@
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{!! $slot !!}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{!! $subcopy !!}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>

View File

@ -0,0 +1,14 @@
<table class="panel" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-content">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-item">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,7 @@
<table class="subcopy" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>

View File

@ -0,0 +1,3 @@
<div class="table">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</div>

View File

@ -0,0 +1,297 @@
/* Base */
body,
body *:not(html):not(style):not(br):not(tr):not(code) {
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
position: relative;
}
body {
-webkit-text-size-adjust: none;
background-color: #ffffff;
color: #52525b;
height: 100%;
line-height: 1.4;
margin: 0;
padding: 0;
width: 100% !important;
}
p,
ul,
ol,
blockquote {
line-height: 1.4;
text-align: left;
}
a {
color: #18181b;
}
a img {
border: none;
}
/* Typography */
h1 {
color: #18181b;
font-size: 18px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h2 {
font-size: 16px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h3 {
font-size: 14px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
p {
font-size: 16px;
line-height: 1.5em;
margin-top: 0;
text-align: left;
}
p.sub {
font-size: 12px;
}
img {
max-width: 100%;
}
/* Layout */
.wrapper {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: #fafafa;
margin: 0;
padding: 0;
width: 100%;
}
.content {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 0;
padding: 0;
width: 100%;
}
/* Header */
.header {
padding: 25px 0;
text-align: center;
}
.header a {
color: #18181b;
font-size: 19px;
font-weight: bold;
text-decoration: none;
}
/* Logo */
.logo {
height: 75px;
margin-top: 15px;
margin-bottom: 10px;
max-height: 75px;
width: 75px;
}
/* Body */
.body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: #fafafa;
border-bottom: 1px solid #fafafa;
border-top: 1px solid #fafafa;
margin: 0;
padding: 0;
width: 100%;
}
.inner-body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
background-color: #ffffff;
border-color: #e4e4e7;
border-radius: 4px;
border-width: 1px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
margin: 0 auto;
padding: 0;
width: 570px;
}
.inner-body a {
word-break: break-all;
}
/* Subcopy */
.subcopy {
border-top: 1px solid #e4e4e7;
margin-top: 25px;
padding-top: 25px;
}
.subcopy p {
font-size: 14px;
}
/* Footer */
.footer {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
margin: 0 auto;
padding: 0;
text-align: center;
width: 570px;
}
.footer p {
color: #a1a1aa;
font-size: 12px;
text-align: center;
}
.footer a {
color: #a1a1aa;
text-decoration: underline;
}
/* Tables */
.table table {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 30px auto;
width: 100%;
}
.table th {
border-bottom: 1px solid #e4e4e7;
margin: 0;
padding-bottom: 8px;
}
.table td {
color: #52525b;
font-size: 15px;
line-height: 18px;
margin: 0;
padding: 10px 0;
}
.content-cell {
max-width: 100vw;
padding: 32px;
}
/* Buttons */
.action {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 30px auto;
padding: 0;
text-align: center;
width: 100%;
float: unset;
}
.button {
-webkit-text-size-adjust: none;
border-radius: 4px;
color: #fff;
display: inline-block;
overflow: hidden;
text-decoration: none;
}
.button-blue,
.button-primary {
background-color: #18181b;
border-bottom: 8px solid #18181b;
border-left: 18px solid #18181b;
border-right: 18px solid #18181b;
border-top: 8px solid #18181b;
}
.button-green,
.button-success {
background-color: #16a34a;
border-bottom: 8px solid #16a34a;
border-left: 18px solid #16a34a;
border-right: 18px solid #16a34a;
border-top: 8px solid #16a34a;
}
.button-red,
.button-error {
background-color: #dc2626;
border-bottom: 8px solid #dc2626;
border-left: 18px solid #dc2626;
border-right: 18px solid #dc2626;
border-top: 8px solid #dc2626;
}
/* Panels */
.panel {
border-left: #18181b solid 4px;
margin: 21px 0;
}
.panel-content {
background-color: #fafafa;
color: #52525b;
padding: 16px;
}
.panel-content p {
color: #52525b;
}
.panel-item {
padding: 0;
}
.panel-item p:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
}
/* Utilities */
.break-all {
word-break: break-all;
}

View File

@ -0,0 +1 @@
{{ $slot }}: {{ $url }}

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -0,0 +1 @@
{{ $slot }}: {{ $url }}

View File

@ -0,0 +1,9 @@
{!! strip_tags($header ?? '') !!}
{!! strip_tags($slot) !!}
@isset($subcopy)
{!! strip_tags($subcopy) !!}
@endisset
{!! strip_tags($footer ?? '') !!}

View File

@ -0,0 +1,27 @@
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{{ $slot }}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{{ $subcopy }}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
© {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.')
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -0,0 +1,53 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendFeedbackEmailJob;
use App\Mail\Drip\FeedbackEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
config(['subscriptions.enabled' => true]);
});
test('feedback email is sent to non-subscribed users', function () {
$user = User::factory()->create();
SendFeedbackEmailJob::dispatchSync($user);
Mail::assertSent(FeedbackEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
$this->assertDatabaseHas('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::Feedback->value,
]);
});
test('feedback email is not sent to subscribed users', function () {
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test123',
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
]);
SendFeedbackEmailJob::dispatchSync($user);
Mail::assertNotSent(FeedbackEmail::class);
});
test('feedback email is not sent if already received', function () {
$user = User::factory()->create();
UserMailLog::factory()->for($user)->feedback()->create();
SendFeedbackEmailJob::dispatchSync($user);
Mail::assertNotSent(FeedbackEmail::class);
});

View File

@ -0,0 +1,71 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendImportHelpEmailJob;
use App\Mail\Drip\ImportHelpEmail;
use App\Models\Transaction;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
config(['subscriptions.enabled' => true]);
});
test('import help email is sent to onboarded users without transactions who are not subscribed', function () {
$user = User::factory()->onboarded()->create();
SendImportHelpEmailJob::dispatchSync($user);
Mail::assertSent(ImportHelpEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
$this->assertDatabaseHas('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::ImportHelp->value,
]);
});
test('import help email is not sent to non-onboarded users', function () {
$user = User::factory()->notOnboarded()->create();
SendImportHelpEmailJob::dispatchSync($user);
Mail::assertNotSent(ImportHelpEmail::class);
});
test('import help email is not sent to users with transactions', function () {
$user = User::factory()->onboarded()->create();
Transaction::factory()->for($user)->create();
SendImportHelpEmailJob::dispatchSync($user);
Mail::assertNotSent(ImportHelpEmail::class);
});
test('import help email is not sent to subscribed users', function () {
$user = User::factory()->onboarded()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test123',
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
]);
SendImportHelpEmailJob::dispatchSync($user);
Mail::assertNotSent(ImportHelpEmail::class);
});
test('import help email is not sent if already received', function () {
$user = User::factory()->onboarded()->create();
UserMailLog::factory()->for($user)->importHelp()->create();
SendImportHelpEmailJob::dispatchSync($user);
Mail::assertNotSent(ImportHelpEmail::class);
});

View File

@ -0,0 +1,50 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendOnboardingReminderEmailJob;
use App\Mail\Drip\OnboardingReminderEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
});
test('onboarding reminder email is sent to non-onboarded users', function () {
$user = User::factory()->notOnboarded()->create();
SendOnboardingReminderEmailJob::dispatchSync($user);
Mail::assertSent(OnboardingReminderEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
$this->assertDatabaseHas('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::OnboardingReminder->value,
]);
});
test('onboarding reminder email is not sent to onboarded users', function () {
$user = User::factory()->onboarded()->create();
SendOnboardingReminderEmailJob::dispatchSync($user);
Mail::assertNotSent(OnboardingReminderEmail::class);
$this->assertDatabaseMissing('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::OnboardingReminder->value,
]);
});
test('onboarding reminder email is not sent if already received', function () {
$user = User::factory()->notOnboarded()->create();
UserMailLog::factory()->for($user)->onboardingReminder()->create();
SendOnboardingReminderEmailJob::dispatchSync($user);
Mail::assertNotSent(OnboardingReminderEmail::class);
});

View File

@ -0,0 +1,74 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendPromoCodeEmailJob;
use App\Mail\Drip\PromoCodeEmail;
use App\Models\Transaction;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
config(['subscriptions.enabled' => true]);
});
test('promo code email is sent to onboarded users with transactions who are not subscribed', function () {
$user = User::factory()->onboarded()->create();
Transaction::factory()->for($user)->create();
SendPromoCodeEmailJob::dispatchSync($user);
Mail::assertSent(PromoCodeEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
$this->assertDatabaseHas('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::PromoCode->value,
]);
});
test('promo code email is not sent to non-onboarded users', function () {
$user = User::factory()->notOnboarded()->create();
Transaction::factory()->for($user)->create();
SendPromoCodeEmailJob::dispatchSync($user);
Mail::assertNotSent(PromoCodeEmail::class);
});
test('promo code email is not sent to users without transactions', function () {
$user = User::factory()->onboarded()->create();
SendPromoCodeEmailJob::dispatchSync($user);
Mail::assertNotSent(PromoCodeEmail::class);
});
test('promo code email is not sent to subscribed users', function () {
$user = User::factory()->onboarded()->create();
Transaction::factory()->for($user)->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test123',
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
]);
SendPromoCodeEmailJob::dispatchSync($user);
Mail::assertNotSent(PromoCodeEmail::class);
});
test('promo code email is not sent if already received', function () {
$user = User::factory()->onboarded()->create();
Transaction::factory()->for($user)->create();
UserMailLog::factory()->for($user)->promoCode()->create();
SendPromoCodeEmailJob::dispatchSync($user);
Mail::assertNotSent(PromoCodeEmail::class);
});

View File

@ -0,0 +1,37 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendWelcomeEmailJob;
use App\Mail\Drip\WelcomeEmail;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
});
test('welcome email is sent and logged', function () {
$user = User::factory()->create();
SendWelcomeEmailJob::dispatchSync($user);
Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
$this->assertDatabaseHas('user_mail_logs', [
'user_id' => $user->id,
'email_type' => DripEmailType::Welcome->value,
]);
});
test('welcome email is not sent if already received', function () {
$user = User::factory()->create();
UserMailLog::factory()->for($user)->welcome()->create();
SendWelcomeEmailJob::dispatchSync($user);
Mail::assertNotSent(WelcomeEmail::class);
});

View File

@ -0,0 +1,82 @@
<?php
use App\Jobs\Drip\SendFeedbackEmailJob;
use App\Jobs\Drip\SendImportHelpEmailJob;
use App\Jobs\Drip\SendOnboardingReminderEmailJob;
use App\Jobs\Drip\SendPromoCodeEmailJob;
use App\Jobs\Drip\SendWelcomeEmailJob;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
Queue::fake();
});
test('all drip email jobs are dispatched when user registers', function () {
$user = User::factory()->create();
event(new Registered($user));
Queue::assertPushed(SendWelcomeEmailJob::class, function ($job) use ($user) {
return $job->user->id === $user->id;
});
Queue::assertPushed(SendOnboardingReminderEmailJob::class, function ($job) use ($user) {
return $job->user->id === $user->id;
});
Queue::assertPushed(SendPromoCodeEmailJob::class, function ($job) use ($user) {
return $job->user->id === $user->id;
});
Queue::assertPushed(SendImportHelpEmailJob::class, function ($job) use ($user) {
return $job->user->id === $user->id;
});
Queue::assertPushed(SendFeedbackEmailJob::class, function ($job) use ($user) {
return $job->user->id === $user->id;
});
});
test('welcome email job is dispatched with delay', function () {
$user = User::factory()->create();
event(new Registered($user));
Queue::assertPushed(SendWelcomeEmailJob::class, function ($job) {
return $job->delay !== null;
});
});
test('onboarding reminder job is dispatched with delay', function () {
$user = User::factory()->create();
event(new Registered($user));
Queue::assertPushed(SendOnboardingReminderEmailJob::class, function ($job) {
return $job->delay !== null;
});
});
test('feedback email job is dispatched with delay', function () {
$user = User::factory()->create();
event(new Registered($user));
Queue::assertPushed(SendFeedbackEmailJob::class, function ($job) {
return $job->delay !== null;
});
});
test('all jobs are dispatched to the emails queue', function () {
$user = User::factory()->create();
event(new Registered($user));
Queue::assertPushedOn('emails', SendWelcomeEmailJob::class);
Queue::assertPushedOn('emails', SendOnboardingReminderEmailJob::class);
Queue::assertPushedOn('emails', SendPromoCodeEmailJob::class);
Queue::assertPushedOn('emails', SendImportHelpEmailJob::class);
Queue::assertPushedOn('emails', SendFeedbackEmailJob::class);
});