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:
parent
3c22453fc6
commit
46c5b13739
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(bun run format)",
|
||||
"Bash(bun run lint)"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"laravel-boost"
|
||||
],
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
12
.env.example
12
.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=
|
||||
|
|
|
|||
|
|
@ -28,3 +28,4 @@ yarn-error.log
|
|||
/.zed
|
||||
.php-cs-fixer.cache
|
||||
/traefik/certs/*.pem
|
||||
.claude/settings.local.json
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
//
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -3,4 +3,5 @@
|
|||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<div class="table">
|
||||
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||
</div>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}: {{ $url }}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}: {{ $url }}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{!! strip_tags($header ?? '') !!}
|
||||
|
||||
{!! strip_tags($slot) !!}
|
||||
@isset($subcopy)
|
||||
|
||||
{!! strip_tags($subcopy) !!}
|
||||
@endisset
|
||||
|
||||
{!! strip_tags($footer ?? '') !!}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue