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\Mail\Drip\AiConsentFollowUpEmail;
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;
use Illuminate\Mail\Mailable;
class SendAiConsentFollowUpEmailJob implements ShouldQueue
class SendAiConsentFollowUpEmailJob extends SendDripEmailJob
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user)
protected function emailType(): DripEmailType
{
$this->onQueue('emails');
return DripEmailType::AiConsentFollowUp;
}
public function handle(): void
protected function buildMail(): Mailable
{
if (! $this->user->canReceiveEmails()) {
return;
}
return new AiConsentFollowUpEmail($this->user);
}
if ($this->user->hasReceivedEmail(DripEmailType::AiConsentFollowUp)) {
return;
}
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(),
]);
protected function shouldSend(): bool
{
return $this->user->aiConsents()->active()->exists();
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,67 +2,15 @@
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;
class AiConsentFollowUpEmail extends Mailable implements ShouldQueue
class AiConsentFollowUpEmail extends DripMail
{
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)
protected function dripSubject(): string
{
$this->onQueue('emails');
return __('Putting AI to work on your finances');
}
public function envelope(): Envelope
protected function template(): string
{
return new Envelope(
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)];
return 'mail.drip.ai-consent-follow-up';
}
}

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;
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;
class FeedbackEmail extends Mailable implements ShouldQueue
class FeedbackEmail extends DripMail
{
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)
protected function dripSubject(): string
{
$this->onQueue('emails');
return __("How's Your Experience So Far?");
}
public function envelope(): Envelope
protected function template(): string
{
return new Envelope(
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)];
return 'mail.drip.feedback';
}
}

View File

@ -2,67 +2,15 @@
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;
class ImportHelpEmail extends Mailable implements ShouldQueue
class ImportHelpEmail extends DripMail
{
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)
protected function dripSubject(): string
{
$this->onQueue('emails');
return __("Let's Import Your Transactions");
}
public function envelope(): Envelope
protected function template(): string
{
return new Envelope(
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)];
return 'mail.drip.import-help';
}
}

View File

@ -2,67 +2,15 @@
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;
class OnboardingReminderEmail extends Mailable implements ShouldQueue
class OnboardingReminderEmail extends DripMail
{
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)
protected function dripSubject(): string
{
$this->onQueue('emails');
return __('Need Help Getting Started?');
}
public function envelope(): Envelope
protected function template(): string
{
return new Envelope(
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)];
return 'mail.drip.onboarding-reminder';
}
}

View File

@ -2,73 +2,20 @@
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;
class PaywallFollowUpEmail extends Mailable implements ShouldQueue
class PaywallFollowUpEmail extends DripMail
{
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)
protected function dripSubject(): string
{
$this->onQueue('emails');
return __('What stopped you from getting started?');
}
public function envelope(): Envelope
protected function template(): string
{
return new Envelope(
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?'),
);
return 'mail.drip.paywall-follow-up';
}
public function content(): Content
protected function repliesToSender(): bool
{
return new Content(
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)];
return true;
}
}

View File

@ -2,68 +2,20 @@
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;
class PromoCodeEmail extends Mailable implements ShouldQueue
class PromoCodeEmail extends DripMail
{
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)
protected function dripSubject(): string
{
$this->onQueue('emails');
return __('Your Founder Discount - 80% Off First Period');
}
public function envelope(): Envelope
protected function template(): string
{
return new Envelope(
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')
);
return 'mail.drip.promo-code';
}
public function content(): Content
protected function contentData(): array
{
return new Content(
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)];
return ['promoCode' => 'FOUNDER'];
}
}

View File

@ -2,67 +2,15 @@
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;
class SubscriptionCancelledEmail extends Mailable implements ShouldQueue
class SubscriptionCancelledEmail extends DripMail
{
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)
protected function dripSubject(): string
{
$this->onQueue('emails');
return __("We're sorry to see you go");
}
public function envelope(): Envelope
protected function template(): string
{
return new Envelope(
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)];
return 'mail.drip.subscription-cancelled';
}
}

View File

@ -2,67 +2,15 @@
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;
class WelcomeEmail extends Mailable implements ShouldQueue
class WelcomeEmail extends DripMail
{
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)
protected function dripSubject(): string
{
$this->onQueue('emails');
return __('Welcome to Whisper Money - Your Privacy-First Finance App');
}
public function envelope(): Envelope
protected function template(): string
{
return new Envelope(
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)];
return 'mail.drip.welcome';
}
}

View File

@ -3,9 +3,11 @@
use App\Mail\BankingConnectionAuthFailedEmail;
use App\Mail\BankTransactionsSyncedEmail;
use App\Mail\BrokenBankLogosReportEmail;
use App\Mail\Drip\AiConsentFollowUpEmail;
use App\Mail\Drip\FeedbackEmail;
use App\Mail\Drip\ImportHelpEmail;
use App\Mail\Drip\OnboardingReminderEmail;
use App\Mail\Drip\PaywallFollowUpEmail;
use App\Mail\Drip\PromoCodeEmail;
use App\Mail\Drip\SubscriptionCancelledEmail;
use App\Mail\Drip\WelcomeEmail;
@ -84,24 +86,44 @@ test('drip mailables use the drip sender', function (string $mailableClass) {
$user = User::factory()->create();
$mailable = match ($mailableClass) {
AiConsentFollowUpEmail::class => new AiConsentFollowUpEmail($user),
WelcomeEmail::class => new WelcomeEmail($user),
FeedbackEmail::class => new FeedbackEmail($user),
ImportHelpEmail::class => new ImportHelpEmail($user),
OnboardingReminderEmail::class => new OnboardingReminderEmail($user),
PaywallFollowUpEmail::class => new PaywallFollowUpEmail($user),
PromoCodeEmail::class => new PromoCodeEmail($user),
SubscriptionCancelledEmail::class => new SubscriptionCancelledEmail($user),
};
expect($mailable->envelope()->from)->toEqual(new Address('hi@whisper.money', 'Álvaro and Víctor'));
})->with([
AiConsentFollowUpEmail::class,
WelcomeEmail::class,
FeedbackEmail::class,
ImportHelpEmail::class,
OnboardingReminderEmail::class,
PaywallFollowUpEmail::class,
PromoCodeEmail::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) {
$user = User::factory()->create();