refactor(drip): extract base mailable and job for the drip email family (#641)

## Why

The drip email family was near copy-paste. Each of the eight mailables
repeated the same constructor, `$tries`/`$backoff`, `drip_from`
envelope, `markdown` + `userName` content, and `RateLimited` middleware
— differing only in subject and template (plus a reply-to on one, an
extra view var on another). Each of the eight jobs repeated the
`canReceiveEmails` / `hasReceivedEmail` guards, the `Mail::send` call,
and the `UserMailLog::create` block — differing only in the email type,
the mailable, and a handful of per-email eligibility checks.

## What

Two abstract bases, so each subclass declares only what varies:

- **`DripMail`** — owns the shared mailable shape. Subclasses declare
`dripSubject()` and `template()`; optional `contentData()` (PromoCode's
`FOUNDER` var) and `repliesToSender()` (PaywallFollowUp) hooks. The
hooks are named `dripSubject()`/`template()` deliberately to avoid
colliding with `Illuminate\Mail\Mailable`'s existing public
`subject()`/`markdown()` methods.
- **`SendDripEmailJob`** — owns the shared `handle()` flow (shared
guards → send → `UserMailLog`). Subclasses declare `emailType()` and
`buildMail()`; an optional `shouldSend()` hook carries the per-email
eligibility rules (pro-plan, onboarding, transactions, bank connections,
AI consent).

Each mailable drops from ~68 to ~13 lines and each job from ~48 to ~18.
Net **−459 lines**, with the send/log logic and envelope construction
now in one place.

## Behavior

No functional change. Subjects, `from`/`reply-to`, queue name, rate
limiting, templates, view data, and every per-job eligibility guard are
preserved. Notably, passing `replyTo: []` for the seven non-reply mails
is identical to the previous omission — `Envelope`'s `replyTo` defaults
to `[]`. Verified: `pest --exclude-testsuite=Browser` **1872 passing, 0
failing**; `pint --test`, `phpstan` (level 5), `format`, `lint` clean.

## Review-driven follow-up commits

Two review agents (architecture/duplication/coverage, and
behavior/regressions) ran against the first commit. The behavior agent
verified all eight jobs' guard logic invert correctly (order +
negations) and that `replyTo: []` ≡ omitted — no regressions. The
coverage agent found the two hook-driven behaviors were untested;
applied, each as its own commit:

- Extended the drip-sender parity test to all eight mailables and
asserted `PaywallFollowUpEmail`'s reply-to (and that a default drip mail
sets none).
- Asserted `PromoCodeEmail` injects the `FOUNDER` promo code into its
view data.

## Deliberately out of scope

- Non-drip mailables (`Waitlist*`, `Banking*`, `UpdateEmail`, …) share
the same `RateLimited('emails')->releaseAfter(1)` middleware and could
later share a queued-mail base too — separate concern.
- **Pre-existing** (not introduced here, noted by review): the job sends
the mail before writing `UserMailLog`, so a failure between the two can
re-enqueue a duplicate; there is no lock/unique constraint on `(user_id,
email_type)`. Worth a separate hardening pass.
This commit is contained in:
Víctor Falcón 2026-07-04 20:51:38 +02:00 committed by GitHub
parent 845f51abb5
commit eccfaa5a7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 298 additions and 735 deletions

View File

@ -4,45 +4,22 @@ namespace App\Jobs\Drip;
use App\Enums\DripEmailType; use App\Enums\DripEmailType;
use App\Mail\Drip\AiConsentFollowUpEmail; use App\Mail\Drip\AiConsentFollowUpEmail;
use App\Models\User; use Illuminate\Mail\Mailable;
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 SendAiConsentFollowUpEmailJob implements ShouldQueue class SendAiConsentFollowUpEmailJob extends SendDripEmailJob
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; protected function emailType(): DripEmailType
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return DripEmailType::AiConsentFollowUp;
} }
public function handle(): void protected function buildMail(): Mailable
{ {
if (! $this->user->canReceiveEmails()) { return new AiConsentFollowUpEmail($this->user);
return; }
}
if ($this->user->hasReceivedEmail(DripEmailType::AiConsentFollowUp)) { protected function shouldSend(): bool
return; {
} return $this->user->aiConsents()->active()->exists();
if (! $this->user->aiConsents()->active()->exists()) {
return;
}
Mail::to($this->user)->send(new AiConsentFollowUpEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::AiConsentFollowUp,
'email_identifier' => DripEmailType::AiConsentFollowUp->value,
'sent_at' => now(),
]);
} }
} }

View File

@ -0,0 +1,61 @@
<?php
namespace App\Jobs\Drip;
use App\Enums\DripEmailType;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
abstract class SendDripEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user)
{
$this->onQueue('emails');
}
abstract protected function emailType(): DripEmailType;
abstract protected function buildMail(): Mailable;
/**
* Per-email eligibility checks beyond the shared "can receive" and
* "not already sent" guards.
*/
protected function shouldSend(): bool
{
return true;
}
public function handle(): void
{
if (! $this->user->canReceiveEmails()) {
return;
}
if ($this->user->hasReceivedEmail($this->emailType())) {
return;
}
if (! $this->shouldSend()) {
return;
}
Mail::to($this->user)->send($this->buildMail());
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => $this->emailType(),
'email_identifier' => $this->emailType()->value,
'sent_at' => now(),
]);
}
}

View File

@ -4,45 +4,22 @@ namespace App\Jobs\Drip;
use App\Enums\DripEmailType; use App\Enums\DripEmailType;
use App\Mail\Drip\FeedbackEmail; use App\Mail\Drip\FeedbackEmail;
use App\Models\User; use Illuminate\Mail\Mailable;
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 class SendFeedbackEmailJob extends SendDripEmailJob
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; protected function emailType(): DripEmailType
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return DripEmailType::Feedback;
} }
public function handle(): void protected function buildMail(): Mailable
{ {
if (! $this->user->canReceiveEmails()) { return new FeedbackEmail($this->user);
return; }
}
if ($this->user->hasReceivedEmail(DripEmailType::Feedback)) { protected function shouldSend(): bool
return; {
} return ! $this->user->hasProPlan();
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,
'email_identifier' => DripEmailType::Feedback->value,
'sent_at' => now(),
]);
} }
} }

View File

@ -4,53 +4,24 @@ namespace App\Jobs\Drip;
use App\Enums\DripEmailType; use App\Enums\DripEmailType;
use App\Mail\Drip\ImportHelpEmail; use App\Mail\Drip\ImportHelpEmail;
use App\Models\User; use Illuminate\Mail\Mailable;
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 class SendImportHelpEmailJob extends SendDripEmailJob
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; protected function emailType(): DripEmailType
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return DripEmailType::ImportHelp;
} }
public function handle(): void protected function buildMail(): Mailable
{ {
if (! $this->user->canReceiveEmails()) { return new ImportHelpEmail($this->user);
return; }
}
if ($this->user->hasReceivedEmail(DripEmailType::ImportHelp)) { protected function shouldSend(): bool
return; {
} return $this->user->isOnboarded()
&& ! $this->user->transactions()->exists()
if (! $this->user->isOnboarded()) { && ! $this->user->hasProPlan();
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,
'email_identifier' => DripEmailType::ImportHelp->value,
'sent_at' => now(),
]);
} }
} }

View File

@ -4,45 +4,22 @@ namespace App\Jobs\Drip;
use App\Enums\DripEmailType; use App\Enums\DripEmailType;
use App\Mail\Drip\OnboardingReminderEmail; use App\Mail\Drip\OnboardingReminderEmail;
use App\Models\User; use Illuminate\Mail\Mailable;
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 class SendOnboardingReminderEmailJob extends SendDripEmailJob
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; protected function emailType(): DripEmailType
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return DripEmailType::OnboardingReminder;
} }
public function handle(): void protected function buildMail(): Mailable
{ {
if (! $this->user->canReceiveEmails()) { return new OnboardingReminderEmail($this->user);
return; }
}
if ($this->user->hasReceivedEmail(DripEmailType::OnboardingReminder)) { protected function shouldSend(): bool
return; {
} return ! $this->user->isOnboarded();
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,
'email_identifier' => DripEmailType::OnboardingReminder->value,
'sent_at' => now(),
]);
} }
} }

View File

@ -4,49 +4,23 @@ namespace App\Jobs\Drip;
use App\Enums\DripEmailType; use App\Enums\DripEmailType;
use App\Mail\Drip\PaywallFollowUpEmail; use App\Mail\Drip\PaywallFollowUpEmail;
use App\Models\User; use Illuminate\Mail\Mailable;
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 SendPaywallFollowUpEmailJob implements ShouldQueue class SendPaywallFollowUpEmailJob extends SendDripEmailJob
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; protected function emailType(): DripEmailType
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return DripEmailType::PaywallFollowUp;
} }
public function handle(): void protected function buildMail(): Mailable
{ {
if (! $this->user->canReceiveEmails()) { return new PaywallFollowUpEmail($this->user);
return; }
}
if ($this->user->hasReceivedEmail(DripEmailType::PaywallFollowUp)) { protected function shouldSend(): bool
return; {
} return ! $this->user->hasProPlan()
&& $this->user->bankingConnections()->exists();
if ($this->user->hasProPlan()) {
return;
}
if (! $this->user->bankingConnections()->exists()) {
return;
}
Mail::to($this->user)->send(new PaywallFollowUpEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::PaywallFollowUp,
'email_identifier' => DripEmailType::PaywallFollowUp->value,
'sent_at' => now(),
]);
} }
} }

View File

@ -4,53 +4,24 @@ namespace App\Jobs\Drip;
use App\Enums\DripEmailType; use App\Enums\DripEmailType;
use App\Mail\Drip\PromoCodeEmail; use App\Mail\Drip\PromoCodeEmail;
use App\Models\User; use Illuminate\Mail\Mailable;
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 class SendPromoCodeEmailJob extends SendDripEmailJob
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; protected function emailType(): DripEmailType
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return DripEmailType::PromoCode;
} }
public function handle(): void protected function buildMail(): Mailable
{ {
if (! $this->user->canReceiveEmails()) { return new PromoCodeEmail($this->user);
return; }
}
if ($this->user->hasReceivedEmail(DripEmailType::PromoCode)) { protected function shouldSend(): bool
return; {
} return $this->user->isOnboarded()
&& $this->user->transactions()->exists()
if (! $this->user->isOnboarded()) { && ! $this->user->hasProPlan();
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,
'email_identifier' => DripEmailType::PromoCode->value,
'sent_at' => now(),
]);
} }
} }

View File

@ -4,41 +4,17 @@ namespace App\Jobs\Drip;
use App\Enums\DripEmailType; use App\Enums\DripEmailType;
use App\Mail\Drip\SubscriptionCancelledEmail; use App\Mail\Drip\SubscriptionCancelledEmail;
use App\Models\User; use Illuminate\Mail\Mailable;
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 SendSubscriptionCancelledEmailJob implements ShouldQueue class SendSubscriptionCancelledEmailJob extends SendDripEmailJob
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; protected function emailType(): DripEmailType
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return DripEmailType::SubscriptionCancelled;
} }
public function handle(): void protected function buildMail(): Mailable
{ {
if (! $this->user->canReceiveEmails()) { return new SubscriptionCancelledEmail($this->user);
return;
}
if ($this->user->hasReceivedEmail(DripEmailType::SubscriptionCancelled)) {
return;
}
Mail::to($this->user)->send(new SubscriptionCancelledEmail($this->user));
UserMailLog::create([
'user_id' => $this->user->id,
'email_type' => DripEmailType::SubscriptionCancelled,
'email_identifier' => DripEmailType::SubscriptionCancelled->value,
'sent_at' => now(),
]);
} }
} }

View File

@ -4,41 +4,17 @@ namespace App\Jobs\Drip;
use App\Enums\DripEmailType; use App\Enums\DripEmailType;
use App\Mail\Drip\WelcomeEmail; use App\Mail\Drip\WelcomeEmail;
use App\Models\User; use Illuminate\Mail\Mailable;
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 class SendWelcomeEmailJob extends SendDripEmailJob
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; protected function emailType(): DripEmailType
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return DripEmailType::Welcome;
} }
public function handle(): void protected function buildMail(): Mailable
{ {
if (! $this->user->canReceiveEmails()) { return new WelcomeEmail($this->user);
return;
}
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,
'email_identifier' => DripEmailType::Welcome->value,
'sent_at' => now(),
]);
} }
} }

View File

@ -2,67 +2,15 @@
namespace App\Mail\Drip; namespace App\Mail\Drip;
use App\Models\User; class AiConsentFollowUpEmail extends DripMail
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class AiConsentFollowUpEmail extends Mailable implements ShouldQueue
{ {
use Queueable, SerializesModels; protected function dripSubject(): string
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return __('Putting AI to work on your finances');
} }
public function envelope(): Envelope protected function template(): string
{ {
return new Envelope( return 'mail.drip.ai-consent-follow-up';
from: new Address(
config('mail.drip_from.address', 'hi@whisper.money'),
config('mail.drip_from.name', 'Álvaro and Víctor'),
),
subject: __('Putting AI to work on your finances'),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.ai-consent-follow-up',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
} }
} }

View File

@ -0,0 +1,94 @@
<?php
namespace App\Mail\Drip;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
abstract class DripMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{
$this->onQueue('emails');
}
abstract protected function dripSubject(): string;
abstract protected function template(): string;
/**
* Extra view data merged alongside the recipient's name.
*
* @return array<string, mixed>
*/
protected function contentData(): array
{
return [];
}
/**
* Whether replies should route back to the drip sender address.
*/
protected function repliesToSender(): bool
{
return false;
}
public function envelope(): Envelope
{
$from = new Address(
config('mail.drip_from.address', 'hi@whisper.money'),
config('mail.drip_from.name', 'Álvaro and Víctor'),
);
return new Envelope(
from: $from,
replyTo: $this->repliesToSender() ? [$from] : [],
subject: $this->dripSubject(),
);
}
public function content(): Content
{
return new Content(
markdown: $this->template(),
with: [
'userName' => $this->user->name,
...$this->contentData(),
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -2,67 +2,15 @@
namespace App\Mail\Drip; namespace App\Mail\Drip;
use App\Models\User; class FeedbackEmail extends DripMail
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class FeedbackEmail extends Mailable implements ShouldQueue
{ {
use Queueable, SerializesModels; protected function dripSubject(): string
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return __("How's Your Experience So Far?");
} }
public function envelope(): Envelope protected function template(): string
{ {
return new Envelope( return 'mail.drip.feedback';
from: new Address(
config('mail.drip_from.address', 'hi@whisper.money'),
config('mail.drip_from.name', 'Álvaro and Víctor'),
),
subject: __("How's Your Experience So Far?"),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.feedback',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
} }
} }

View File

@ -2,67 +2,15 @@
namespace App\Mail\Drip; namespace App\Mail\Drip;
use App\Models\User; class ImportHelpEmail extends DripMail
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class ImportHelpEmail extends Mailable implements ShouldQueue
{ {
use Queueable, SerializesModels; protected function dripSubject(): string
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return __("Let's Import Your Transactions");
} }
public function envelope(): Envelope protected function template(): string
{ {
return new Envelope( return 'mail.drip.import-help';
from: new Address(
config('mail.drip_from.address', 'hi@whisper.money'),
config('mail.drip_from.name', 'Álvaro and Víctor'),
),
subject: __("Let's Import Your Transactions"),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.import-help',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
} }
} }

View File

@ -2,67 +2,15 @@
namespace App\Mail\Drip; namespace App\Mail\Drip;
use App\Models\User; class OnboardingReminderEmail extends DripMail
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class OnboardingReminderEmail extends Mailable implements ShouldQueue
{ {
use Queueable, SerializesModels; protected function dripSubject(): string
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return __('Need Help Getting Started?');
} }
public function envelope(): Envelope protected function template(): string
{ {
return new Envelope( return 'mail.drip.onboarding-reminder';
from: new Address(
config('mail.drip_from.address', 'hi@whisper.money'),
config('mail.drip_from.name', 'Álvaro and Víctor'),
),
subject: __('Need Help Getting Started?'),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.onboarding-reminder',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
} }
} }

View File

@ -2,73 +2,20 @@
namespace App\Mail\Drip; namespace App\Mail\Drip;
use App\Models\User; class PaywallFollowUpEmail extends DripMail
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class PaywallFollowUpEmail extends Mailable implements ShouldQueue
{ {
use Queueable, SerializesModels; protected function dripSubject(): string
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return __('What stopped you from getting started?');
} }
public function envelope(): Envelope protected function template(): string
{ {
return new Envelope( return 'mail.drip.paywall-follow-up';
from: new Address(
config('mail.drip_from.address', 'hi@whisper.money'),
config('mail.drip_from.name', 'Álvaro and Víctor'),
),
replyTo: [
new Address(
config('mail.drip_from.address', 'hi@whisper.money'),
config('mail.drip_from.name', 'Álvaro and Víctor'),
),
],
subject: __('What stopped you from getting started?'),
);
} }
public function content(): Content protected function repliesToSender(): bool
{ {
return new Content( return true;
markdown: 'mail.drip.paywall-follow-up',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
} }
} }

View File

@ -2,68 +2,20 @@
namespace App\Mail\Drip; namespace App\Mail\Drip;
use App\Models\User; class PromoCodeEmail extends DripMail
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class PromoCodeEmail extends Mailable implements ShouldQueue
{ {
use Queueable, SerializesModels; protected function dripSubject(): string
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return __('Your Founder Discount - 80% Off First Period');
} }
public function envelope(): Envelope protected function template(): string
{ {
return new Envelope( return 'mail.drip.promo-code';
from: new Address(
config('mail.drip_from.address', 'hi@whisper.money'),
config('mail.drip_from.name', 'Álvaro and Víctor'),
),
subject: __('Your Founder Discount - 80% Off First Period')
);
} }
public function content(): Content protected function contentData(): array
{ {
return new Content( return ['promoCode' => 'FOUNDER'];
markdown: 'mail.drip.promo-code',
with: [
'userName' => $this->user->name,
'promoCode' => 'FOUNDER',
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
} }
} }

View File

@ -2,67 +2,15 @@
namespace App\Mail\Drip; namespace App\Mail\Drip;
use App\Models\User; class SubscriptionCancelledEmail extends DripMail
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class SubscriptionCancelledEmail extends Mailable implements ShouldQueue
{ {
use Queueable, SerializesModels; protected function dripSubject(): string
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return __("We're sorry to see you go");
} }
public function envelope(): Envelope protected function template(): string
{ {
return new Envelope( return 'mail.drip.subscription-cancelled';
from: new Address(
config('mail.drip_from.address', 'hi@whisper.money'),
config('mail.drip_from.name', 'Álvaro and Víctor'),
),
subject: __("We're sorry to see you go"),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.subscription-cancelled',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
} }
} }

View File

@ -2,67 +2,15 @@
namespace App\Mail\Drip; namespace App\Mail\Drip;
use App\Models\User; class WelcomeEmail extends DripMail
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class WelcomeEmail extends Mailable implements ShouldQueue
{ {
use Queueable, SerializesModels; protected function dripSubject(): string
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
/**
* The number of seconds to wait before retrying the job.
*
* @var array<int, int>
*/
public $backoff = [2, 5, 10, 30];
public function __construct(public User $user)
{ {
$this->onQueue('emails'); return __('Welcome to Whisper Money - Your Privacy-First Finance App');
} }
public function envelope(): Envelope protected function template(): string
{ {
return new Envelope( return 'mail.drip.welcome';
from: new Address(
config('mail.drip_from.address', 'hi@whisper.money'),
config('mail.drip_from.name', 'Álvaro and Víctor'),
),
subject: __('Welcome to Whisper Money - Your Privacy-First Finance App'),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.drip.welcome',
with: [
'userName' => $this->user->name,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
} }
} }

View File

@ -3,9 +3,11 @@
use App\Mail\BankingConnectionAuthFailedEmail; use App\Mail\BankingConnectionAuthFailedEmail;
use App\Mail\BankTransactionsSyncedEmail; use App\Mail\BankTransactionsSyncedEmail;
use App\Mail\BrokenBankLogosReportEmail; use App\Mail\BrokenBankLogosReportEmail;
use App\Mail\Drip\AiConsentFollowUpEmail;
use App\Mail\Drip\FeedbackEmail; use App\Mail\Drip\FeedbackEmail;
use App\Mail\Drip\ImportHelpEmail; use App\Mail\Drip\ImportHelpEmail;
use App\Mail\Drip\OnboardingReminderEmail; use App\Mail\Drip\OnboardingReminderEmail;
use App\Mail\Drip\PaywallFollowUpEmail;
use App\Mail\Drip\PromoCodeEmail; use App\Mail\Drip\PromoCodeEmail;
use App\Mail\Drip\SubscriptionCancelledEmail; use App\Mail\Drip\SubscriptionCancelledEmail;
use App\Mail\Drip\WelcomeEmail; use App\Mail\Drip\WelcomeEmail;
@ -84,24 +86,44 @@ test('drip mailables use the drip sender', function (string $mailableClass) {
$user = User::factory()->create(); $user = User::factory()->create();
$mailable = match ($mailableClass) { $mailable = match ($mailableClass) {
AiConsentFollowUpEmail::class => new AiConsentFollowUpEmail($user),
WelcomeEmail::class => new WelcomeEmail($user), WelcomeEmail::class => new WelcomeEmail($user),
FeedbackEmail::class => new FeedbackEmail($user), FeedbackEmail::class => new FeedbackEmail($user),
ImportHelpEmail::class => new ImportHelpEmail($user), ImportHelpEmail::class => new ImportHelpEmail($user),
OnboardingReminderEmail::class => new OnboardingReminderEmail($user), OnboardingReminderEmail::class => new OnboardingReminderEmail($user),
PaywallFollowUpEmail::class => new PaywallFollowUpEmail($user),
PromoCodeEmail::class => new PromoCodeEmail($user), PromoCodeEmail::class => new PromoCodeEmail($user),
SubscriptionCancelledEmail::class => new SubscriptionCancelledEmail($user), SubscriptionCancelledEmail::class => new SubscriptionCancelledEmail($user),
}; };
expect($mailable->envelope()->from)->toEqual(new Address('hi@whisper.money', 'Álvaro and Víctor')); expect($mailable->envelope()->from)->toEqual(new Address('hi@whisper.money', 'Álvaro and Víctor'));
})->with([ })->with([
AiConsentFollowUpEmail::class,
WelcomeEmail::class, WelcomeEmail::class,
FeedbackEmail::class, FeedbackEmail::class,
ImportHelpEmail::class, ImportHelpEmail::class,
OnboardingReminderEmail::class, OnboardingReminderEmail::class,
PaywallFollowUpEmail::class,
PromoCodeEmail::class, PromoCodeEmail::class,
SubscriptionCancelledEmail::class, SubscriptionCancelledEmail::class,
]); ]);
test('only the paywall follow-up sets a reply-to, back to the drip sender', function () {
$user = User::factory()->create();
expect((new PaywallFollowUpEmail($user))->envelope()->replyTo)
->toEqual([new Address('hi@whisper.money', 'Álvaro and Víctor')]);
expect((new WelcomeEmail($user))->envelope()->replyTo)->toBe([]);
});
test('promo code email adds the founder promo code to its view data', function () {
$user = User::factory()->create();
expect((new PromoCodeEmail($user))->content()->with)
->toEqual(['userName' => $user->name, 'promoCode' => 'FOUNDER']);
});
test('default sender is used for active non-drip mailables', function (string $mailableClass) { test('default sender is used for active non-drip mailables', function (string $mailableClass) {
$user = User::factory()->create(); $user = User::factory()->create();