diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 8cc87d09..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(bun run format)", - "Bash(bun run lint)" - ] - }, - "enabledMcpjsonServers": [ - "laravel-boost" - ], - "enableAllProjectMcpServers": true -} diff --git a/.env.example b/.env.example index 4d3b2fd2..6c6b0d18 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index e08f740a..0b92df8b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ yarn-error.log /.zed .php-cs-fixer.cache /traefik/certs/*.pem +.claude/settings.local.json diff --git a/app/Console/Commands/SendTestEmails.php b/app/Console/Commands/SendTestEmails.php new file mode 100644 index 00000000..248805a8 --- /dev/null +++ b/app/Console/Commands/SendTestEmails.php @@ -0,0 +1,63 @@ +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; + } +} diff --git a/app/Enums/DripEmailType.php b/app/Enums/DripEmailType.php new file mode 100644 index 00000000..6167b6a1 --- /dev/null +++ b/app/Enums/DripEmailType.php @@ -0,0 +1,12 @@ +getUser(); + $authPassword = $request->getPassword(); + + if ($authUser !== $username || $authPassword !== $password) { + return response('Unauthorized', 401, [ + 'WWW-Authenticate' => 'Basic realm="Horizon"', + ]); + } + + return $next($request); + } +} diff --git a/app/Jobs/Drip/SendFeedbackEmailJob.php b/app/Jobs/Drip/SendFeedbackEmailJob.php new file mode 100644 index 00000000..2b406730 --- /dev/null +++ b/app/Jobs/Drip/SendFeedbackEmailJob.php @@ -0,0 +1,43 @@ +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(), + ]); + } +} diff --git a/app/Jobs/Drip/SendImportHelpEmailJob.php b/app/Jobs/Drip/SendImportHelpEmailJob.php new file mode 100644 index 00000000..30a6758f --- /dev/null +++ b/app/Jobs/Drip/SendImportHelpEmailJob.php @@ -0,0 +1,51 @@ +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(), + ]); + } +} diff --git a/app/Jobs/Drip/SendOnboardingReminderEmailJob.php b/app/Jobs/Drip/SendOnboardingReminderEmailJob.php new file mode 100644 index 00000000..3269e4b9 --- /dev/null +++ b/app/Jobs/Drip/SendOnboardingReminderEmailJob.php @@ -0,0 +1,43 @@ +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(), + ]); + } +} diff --git a/app/Jobs/Drip/SendPromoCodeEmailJob.php b/app/Jobs/Drip/SendPromoCodeEmailJob.php new file mode 100644 index 00000000..365c4a8b --- /dev/null +++ b/app/Jobs/Drip/SendPromoCodeEmailJob.php @@ -0,0 +1,51 @@ +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(), + ]); + } +} diff --git a/app/Jobs/Drip/SendWelcomeEmailJob.php b/app/Jobs/Drip/SendWelcomeEmailJob.php new file mode 100644 index 00000000..79d0da04 --- /dev/null +++ b/app/Jobs/Drip/SendWelcomeEmailJob.php @@ -0,0 +1,39 @@ +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(), + ]); + } +} diff --git a/app/Listeners/ScheduleDripEmailsListener.php b/app/Listeners/ScheduleDripEmailsListener.php new file mode 100644 index 00000000..781537bc --- /dev/null +++ b/app/Listeners/ScheduleDripEmailsListener.php @@ -0,0 +1,24 @@ +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)); + } +} diff --git a/app/Mail/Drip/FeedbackEmail.php b/app/Mail/Drip/FeedbackEmail.php new file mode 100644 index 00000000..7431292d --- /dev/null +++ b/app/Mail/Drip/FeedbackEmail.php @@ -0,0 +1,34 @@ +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, + ], + ); + } +} diff --git a/app/Mail/Drip/ImportHelpEmail.php b/app/Mail/Drip/ImportHelpEmail.php new file mode 100644 index 00000000..b905efad --- /dev/null +++ b/app/Mail/Drip/ImportHelpEmail.php @@ -0,0 +1,34 @@ +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, + ], + ); + } +} diff --git a/app/Mail/Drip/OnboardingReminderEmail.php b/app/Mail/Drip/OnboardingReminderEmail.php new file mode 100644 index 00000000..7affed34 --- /dev/null +++ b/app/Mail/Drip/OnboardingReminderEmail.php @@ -0,0 +1,34 @@ +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, + ], + ); + } +} diff --git a/app/Mail/Drip/PromoCodeEmail.php b/app/Mail/Drip/PromoCodeEmail.php new file mode 100644 index 00000000..c73db43f --- /dev/null +++ b/app/Mail/Drip/PromoCodeEmail.php @@ -0,0 +1,35 @@ +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', + ], + ); + } +} diff --git a/app/Mail/Drip/WelcomeEmail.php b/app/Mail/Drip/WelcomeEmail.php new file mode 100644 index 00000000..9fce4999 --- /dev/null +++ b/app/Mail/Drip/WelcomeEmail.php @@ -0,0 +1,34 @@ +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, + ], + ); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 75ae5fc3..f58441a5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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')) { diff --git a/app/Models/UserMailLog.php b/app/Models/UserMailLog.php new file mode 100644 index 00000000..68d1d0f0 --- /dev/null +++ b/app/Models/UserMailLog.php @@ -0,0 +1,33 @@ + DripEmailType::class, + 'sent_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 56f8e97d..e5b9e75c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); } } diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php new file mode 100644 index 00000000..cd31c95b --- /dev/null +++ b/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,47 @@ +email, [ + // + ]); + }); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 0ad9c573..63da17d7 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -3,4 +3,5 @@ return [ App\Providers\AppServiceProvider::class, App\Providers\FortifyServiceProvider::class, + App\Providers\HorizonServiceProvider::class, ]; diff --git a/compose.yaml b/compose.yaml index 8d8d72b4..7931a037 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/composer.json b/composer.json index d9871fc5..75ab9a00 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/composer.lock b/composer.lock index 5d456f88..17851d4b 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/horizon.php b/config/horizon.php new file mode 100644 index 00000000..0f272708 --- /dev/null +++ b/config/horizon.php @@ -0,0 +1,262 @@ + 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, + ], + ], + ], +]; diff --git a/database/factories/UserMailLogFactory.php b/database/factories/UserMailLogFactory.php new file mode 100644 index 00000000..67828319 --- /dev/null +++ b/database/factories/UserMailLogFactory.php @@ -0,0 +1,62 @@ + + */ +class UserMailLogFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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, + ]); + } +} diff --git a/database/migrations/2025_12_16_151952_create_user_mail_logs_table.php b/database/migrations/2025_12_16_151952_create_user_mail_logs_table.php new file mode 100644 index 00000000..4db80e29 --- /dev/null +++ b/database/migrations/2025_12_16_151952_create_user_mail_logs_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf index dd74b54f..77aa51be 100644 --- a/docker/supervisor/supervisord.conf +++ b/docker/supervisor/supervisord.conf @@ -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 diff --git a/resources/views/mail/drip/feedback.blade.php b/resources/views/mail/drip/feedback.blade.php new file mode 100644 index 00000000..5abb545e --- /dev/null +++ b/resources/views/mail/drip/feedback.blade.php @@ -0,0 +1,25 @@ + +# 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,
+Víctor F,
+Founder of Whisper Money +
diff --git a/resources/views/mail/drip/import-help.blade.php b/resources/views/mail/drip/import-help.blade.php new file mode 100644 index 00000000..e568ef9c --- /dev/null +++ b/resources/views/mail/drip/import-help.blade.php @@ -0,0 +1,32 @@ + +# 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. + + +Go to Dashboard + + +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,
+Víctor F,
+Founder of Whisper Money +
diff --git a/resources/views/mail/drip/onboarding-reminder.blade.php b/resources/views/mail/drip/onboarding-reminder.blade.php new file mode 100644 index 00000000..4b7e8973 --- /dev/null +++ b/resources/views/mail/drip/onboarding-reminder.blade.php @@ -0,0 +1,28 @@ + +# 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. + + +Continue Setup + + +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,
+Víctor F,
+Founder of Whisper Money +
diff --git a/resources/views/mail/drip/promo-code.blade.php b/resources/views/mail/drip/promo-code.blade.php new file mode 100644 index 00000000..69c4c084 --- /dev/null +++ b/resources/views/mail/drip/promo-code.blade.php @@ -0,0 +1,32 @@ + +# 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. + + +Use code **{{ $promoCode }}** to get your first month for just **$1** + + +This gives you full access to all Whisper Money features: + +- Unlimited transaction imports +- Automated categorization rules +- Multiple account tracking +- End-to-end encrypted storage + + +Claim Your Discount + + +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,
+Víctor F,
+Founder of Whisper Money +
diff --git a/resources/views/mail/drip/welcome.blade.php b/resources/views/mail/drip/welcome.blade.php new file mode 100644 index 00000000..e3577c0e --- /dev/null +++ b/resources/views/mail/drip/welcome.blade.php @@ -0,0 +1,31 @@ + +# 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. + +
+ +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,
+Víctor F,
+Founder of Whisper Money +
diff --git a/resources/views/vendor/mail/html/button.blade.php b/resources/views/vendor/mail/html/button.blade.php new file mode 100644 index 00000000..050e969d --- /dev/null +++ b/resources/views/vendor/mail/html/button.blade.php @@ -0,0 +1,24 @@ +@props([ + 'url', + 'color' => 'primary', + 'align' => 'center', +]) + + + + + diff --git a/resources/views/vendor/mail/html/footer.blade.php b/resources/views/vendor/mail/html/footer.blade.php new file mode 100644 index 00000000..3ff41f89 --- /dev/null +++ b/resources/views/vendor/mail/html/footer.blade.php @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/resources/views/vendor/mail/html/header.blade.php b/resources/views/vendor/mail/html/header.blade.php new file mode 100644 index 00000000..a20b6edb --- /dev/null +++ b/resources/views/vendor/mail/html/header.blade.php @@ -0,0 +1,23 @@ +@props(['url']) + + + +@if (trim($slot) === 'Laravel') +troke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: #18181b;"> + + + + + + + + + +@elseif (trim($slot) === config('app.name')) + +@else +{!! $slot !!} +@endif + + + diff --git a/resources/views/vendor/mail/html/layout.blade.php b/resources/views/vendor/mail/html/layout.blade.php new file mode 100644 index 00000000..0fa6b82f --- /dev/null +++ b/resources/views/vendor/mail/html/layout.blade.php @@ -0,0 +1,58 @@ + + + +{{ config('app.name') }} + + + + + +{!! $head ?? '' !!} + + + + + + + + + + diff --git a/resources/views/vendor/mail/html/message.blade.php b/resources/views/vendor/mail/html/message.blade.php new file mode 100644 index 00000000..a16bace0 --- /dev/null +++ b/resources/views/vendor/mail/html/message.blade.php @@ -0,0 +1,27 @@ + +{{-- Header --}} + + +{{ config('app.name') }} + + + +{{-- Body --}} +{!! $slot !!} + +{{-- Subcopy --}} +@isset($subcopy) + + +{!! $subcopy !!} + + +@endisset + +{{-- Footer --}} + + +© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }} + + + diff --git a/resources/views/vendor/mail/html/panel.blade.php b/resources/views/vendor/mail/html/panel.blade.php new file mode 100644 index 00000000..2975a60a --- /dev/null +++ b/resources/views/vendor/mail/html/panel.blade.php @@ -0,0 +1,14 @@ + + + + + + diff --git a/resources/views/vendor/mail/html/subcopy.blade.php b/resources/views/vendor/mail/html/subcopy.blade.php new file mode 100644 index 00000000..790ce6c2 --- /dev/null +++ b/resources/views/vendor/mail/html/subcopy.blade.php @@ -0,0 +1,7 @@ + + + + + diff --git a/resources/views/vendor/mail/html/table.blade.php b/resources/views/vendor/mail/html/table.blade.php new file mode 100644 index 00000000..a5f3348b --- /dev/null +++ b/resources/views/vendor/mail/html/table.blade.php @@ -0,0 +1,3 @@ +
+{{ Illuminate\Mail\Markdown::parse($slot) }} +
diff --git a/resources/views/vendor/mail/html/themes/default.css b/resources/views/vendor/mail/html/themes/default.css new file mode 100644 index 00000000..80465b22 --- /dev/null +++ b/resources/views/vendor/mail/html/themes/default.css @@ -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; +} diff --git a/resources/views/vendor/mail/text/button.blade.php b/resources/views/vendor/mail/text/button.blade.php new file mode 100644 index 00000000..97444ebd --- /dev/null +++ b/resources/views/vendor/mail/text/button.blade.php @@ -0,0 +1 @@ +{{ $slot }}: {{ $url }} diff --git a/resources/views/vendor/mail/text/footer.blade.php b/resources/views/vendor/mail/text/footer.blade.php new file mode 100644 index 00000000..3338f620 --- /dev/null +++ b/resources/views/vendor/mail/text/footer.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/resources/views/vendor/mail/text/header.blade.php b/resources/views/vendor/mail/text/header.blade.php new file mode 100644 index 00000000..97444ebd --- /dev/null +++ b/resources/views/vendor/mail/text/header.blade.php @@ -0,0 +1 @@ +{{ $slot }}: {{ $url }} diff --git a/resources/views/vendor/mail/text/layout.blade.php b/resources/views/vendor/mail/text/layout.blade.php new file mode 100644 index 00000000..ec58e83c --- /dev/null +++ b/resources/views/vendor/mail/text/layout.blade.php @@ -0,0 +1,9 @@ +{!! strip_tags($header ?? '') !!} + +{!! strip_tags($slot) !!} +@isset($subcopy) + +{!! strip_tags($subcopy) !!} +@endisset + +{!! strip_tags($footer ?? '') !!} diff --git a/resources/views/vendor/mail/text/message.blade.php b/resources/views/vendor/mail/text/message.blade.php new file mode 100644 index 00000000..80bce211 --- /dev/null +++ b/resources/views/vendor/mail/text/message.blade.php @@ -0,0 +1,27 @@ + + {{-- Header --}} + + + {{ config('app.name') }} + + + + {{-- Body --}} + {{ $slot }} + + {{-- Subcopy --}} + @isset($subcopy) + + + {{ $subcopy }} + + + @endisset + + {{-- Footer --}} + + + © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.') + + + diff --git a/resources/views/vendor/mail/text/panel.blade.php b/resources/views/vendor/mail/text/panel.blade.php new file mode 100644 index 00000000..3338f620 --- /dev/null +++ b/resources/views/vendor/mail/text/panel.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/resources/views/vendor/mail/text/subcopy.blade.php b/resources/views/vendor/mail/text/subcopy.blade.php new file mode 100644 index 00000000..3338f620 --- /dev/null +++ b/resources/views/vendor/mail/text/subcopy.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/resources/views/vendor/mail/text/table.blade.php b/resources/views/vendor/mail/text/table.blade.php new file mode 100644 index 00000000..3338f620 --- /dev/null +++ b/resources/views/vendor/mail/text/table.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/tests/Feature/Jobs/Drip/SendFeedbackEmailJobTest.php b/tests/Feature/Jobs/Drip/SendFeedbackEmailJobTest.php new file mode 100644 index 00000000..06f57bd8 --- /dev/null +++ b/tests/Feature/Jobs/Drip/SendFeedbackEmailJobTest.php @@ -0,0 +1,53 @@ + 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); +}); diff --git a/tests/Feature/Jobs/Drip/SendImportHelpEmailJobTest.php b/tests/Feature/Jobs/Drip/SendImportHelpEmailJobTest.php new file mode 100644 index 00000000..cab7e3c3 --- /dev/null +++ b/tests/Feature/Jobs/Drip/SendImportHelpEmailJobTest.php @@ -0,0 +1,71 @@ + 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); +}); diff --git a/tests/Feature/Jobs/Drip/SendOnboardingReminderEmailJobTest.php b/tests/Feature/Jobs/Drip/SendOnboardingReminderEmailJobTest.php new file mode 100644 index 00000000..ddc97278 --- /dev/null +++ b/tests/Feature/Jobs/Drip/SendOnboardingReminderEmailJobTest.php @@ -0,0 +1,50 @@ +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); +}); diff --git a/tests/Feature/Jobs/Drip/SendPromoCodeEmailJobTest.php b/tests/Feature/Jobs/Drip/SendPromoCodeEmailJobTest.php new file mode 100644 index 00000000..5e2644eb --- /dev/null +++ b/tests/Feature/Jobs/Drip/SendPromoCodeEmailJobTest.php @@ -0,0 +1,74 @@ + 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); +}); diff --git a/tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php b/tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php new file mode 100644 index 00000000..fd88a77c --- /dev/null +++ b/tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php @@ -0,0 +1,37 @@ +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); +}); diff --git a/tests/Feature/Listeners/ScheduleDripEmailsListenerTest.php b/tests/Feature/Listeners/ScheduleDripEmailsListenerTest.php new file mode 100644 index 00000000..dd1aa11a --- /dev/null +++ b/tests/Feature/Listeners/ScheduleDripEmailsListenerTest.php @@ -0,0 +1,82 @@ +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); +});