feat(email): follow up after post-onboarding AI consent (#596)

## What

Adds a lifecycle email sent **2 days after a user records AI consent**,
but only when that consent was given **outside of onboarding** — i.e.
more than 3 days after sign-up. This targets users who deliberately
opted into AI later (e.g. via the billing toggle / transactions banner
gated by the `AiConsentSettings` flag), not those who consented during
initial setup.

## How

- **`email:ai-consent-follow-up`** scheduled command (daily, 10:15
Europe/Madrid) selects active consents accepted exactly 2 days ago,
skips onboarding-era ones (`accepted_at <= created_at + 3 days`), and
queues a job per user.
- **`SendAiConsentFollowUpEmailJob`** sends the mailable and records a
`UserMailLog`. Dedup, locale and the queued-email plumbing all reuse the
existing drip system — **no migration**.
- Email is localised automatically via the user's `HasLocalePreference`,
like every other drip email.

## Design notes

- **Scheduled command, not a 2-day delayed job** — survives worker
restarts and respects consent revoked in the meantime.
- **Exact "2 days ago" window**, matching the existing paywall drip. If
the scheduler misses a day that cohort isn't retried; kept consistent
with the other drips on purpose.
- Copy is a first draft — open to wording tweaks.

## Tests

`tests/Feature/SendAiConsentFollowUpEmailsCommandTest.php` — 9 tests
covering the happy path, onboarding exclusion (incl. the exact-3-days
boundary), timing window, revoked consent, the disabled-drip gate,
single-send + mail log, no-resend, and locale rendering. All green, Pint
clean.
This commit is contained in:
Víctor Falcón 2026-06-26 19:56:06 +02:00 committed by GitHub
parent 291cfbe261
commit 934d834ab3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 348 additions and 1 deletions

View File

@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use App\Jobs\Drip\SendAiConsentFollowUpEmailJob;
use App\Models\AiConsent;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
class SendAiConsentFollowUpEmailsCommand extends Command
{
protected $signature = 'email:ai-consent-follow-up';
protected $description = 'Queue the AI consent follow-up email for users who opted into AI two days ago, outside of onboarding';
/**
* Consent given more than this many days after sign-up is treated as a
* deliberate, post-onboarding opt-in (the audience for this email).
*/
private const ONBOARDING_GRACE_DAYS = 3;
public function handle(): int
{
if (! config('mail.drip_emails_enabled')) {
$this->info('Drip emails are disabled. Nothing to do.');
return self::SUCCESS;
}
$queued = 0;
AiConsent::query()
->active()
->whereDate('accepted_at', today()->subDays(2))
->with('user')
->chunkById(100, function (Collection $consents) use (&$queued): void {
foreach ($consents as $consent) {
$user = $consent->user;
if ($user === null) {
continue;
}
if ($consent->accepted_at->lessThanOrEqualTo($user->created_at->copy()->addDays(self::ONBOARDING_GRACE_DAYS))) {
continue;
}
SendAiConsentFollowUpEmailJob::dispatch($user);
$queued++;
}
});
$this->info("Queued {$queued} AI consent follow-up email(s).");
return self::SUCCESS;
}
}

View File

@ -12,5 +12,6 @@ enum DripEmailType: string
case Feedback = 'feedback';
case SubscriptionCancelled = 'subscription_cancelled';
case PaywallFollowUp = 'paywall_follow_up';
case AiConsentFollowUp = 'ai_consent_follow_up';
case Update = 'update';
}

View File

@ -0,0 +1,48 @@
<?php
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;
class SendAiConsentFollowUpEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user)
{
$this->onQueue('emails');
}
public function handle(): void
{
if (! $this->user->canReceiveEmails()) {
return;
}
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(),
]);
}
}

View File

@ -0,0 +1,68 @@
<?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;
class AiConsentFollowUpEmail 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');
}
public function envelope(): Envelope
{
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)];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Carbon\Carbon;
use Database\Factories\AiConsentFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -9,6 +10,10 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property Carbon $accepted_at
* @property ?Carbon $revoked_at
*/
class AiConsent extends Model
{
/** @use HasFactory<AiConsentFactory> */

View File

@ -2166,5 +2166,14 @@
"Connect your Interactive Brokers account using a Flex Web Service token and Query ID.": "Conecta tu cuenta de Interactive Brokers usando un token del Flex Web Service y un Query ID.",
"Enter your Flex Web Service token and Query ID to connect your Interactive Brokers account.": "Introduce tu token del Flex Web Service y el Query ID para conectar tu cuenta de Interactive Brokers.",
"In Client Portal, create an Activity Flex Query including the \"Net Asset Value (NAV)\" and \"Open Positions\" sections, then generate a Flex Web Service token under": "En el Client Portal, crea una Activity Flex Query que incluya las secciones \"Net Asset Value (NAV)\" y \"Open Positions\", y luego genera un token del Flex Web Service en",
"Performance & Reports → Flex Queries": "Rendimiento e Informes → Flex Queries"
"Performance & Reports → Flex Queries": "Rendimiento e Informes → Flex Queries",
"Putting AI to work on your finances": "Ponemos la IA a trabajar en tus finanzas",
"Your AI assistant is hard at work, :name": "Tu asistente de IA está trabajando duro, :name",
"A couple of days ago you turned on AI-powered insights in Whisper Money. We wanted to check in and show you what it has been doing for you behind the scenes.": "Hace un par de días activaste los análisis con IA en Whisper Money. Queríamos saber cómo te va y enseñarte lo que ha estado haciendo por ti entre bastidores.",
"Your transactions, categorised automatically": "Tus transacciones, categorizadas automáticamente",
"Instead of sorting every transaction by hand, our AI suggests a category for each one — so your spending breakdown stays accurate and up to date with almost no effort from you.": "En lugar de ordenar cada transacción a mano, nuestra IA sugiere una categoría para cada una, para que el desglose de tus gastos se mantenga preciso y actualizado casi sin esfuerzo por tu parte.",
"See your categorised transactions": "Ver tus transacciones categorizadas",
"Private by design, always": "Privado por diseño, siempre",
"Turning on AI never changes who owns your data — you do. We only use it to help you understand your own finances, and you can revoke this consent at any time from your settings.": "Activar la IA nunca cambia quién es el dueño de tus datos: lo eres tú. Solo la usamos para ayudarte a entender tus propias finanzas, y puedes revocar este consentimiento cuando quieras desde tus ajustes.",
"If you have any questions, **just reply to this email**. We personally read every message.": "Si tienes cualquier duda, **responde a este correo**. Leemos personalmente cada mensaje."
}

View File

@ -0,0 +1,23 @@
<x-mail::message>
# {{ __('Your AI assistant is hard at work, :name', ['name' => $userName]) }}
{{ __('A couple of days ago you turned on AI-powered insights in Whisper Money. We wanted to check in and show you what it has been doing for you behind the scenes.') }}
## {{ __('Your transactions, categorised automatically') }}
{{ __('Instead of sorting every transaction by hand, our AI suggests a category for each one — so your spending breakdown stays accurate and up to date with almost no effort from you.') }}
<x-mail::button :url="route('transactions.index', ['category_source' => 'ai'])">
{{ __('See your categorised transactions') }}
</x-mail::button>
## {{ __('Private by design, always') }}
{{ __('Turning on AI never changes who owns your data — you do. We only use it to help you understand your own finances, and you can revoke this consent at any time from your settings.') }}
{{ __('If you have any questions, **just reply to this email**. We personally read every message.') }}
{{ __('Best,') }}<br>
{{ __('Víctor & Álvaro') }}<br>
{{ __('Founders of Whisper Money') }}
</x-mail::message>

View File

@ -12,6 +12,7 @@ Schedule::command('resend:sync-leads')->dailyAt('03:00');
Schedule::command('leads:send-invitations --force --limit=150')->dailyAt('09:00');
Schedule::command('leads:send-re-invitations --force')->dailyAt('09:00');
Schedule::command('email:paywall-follow-up')->dailyAt('10:00')->timezone('Europe/Madrid');
Schedule::command('email:ai-consent-follow-up')->dailyAt('10:15')->timezone('Europe/Madrid');
Schedule::command('stats:daily-report')->dailyAt('09:00')->timezone('Europe/Madrid');
Schedule::command('stats:ai-cohort-report')->monthlyOn(1, '09:00')->timezone('Europe/Madrid');
Schedule::command('stats:stuck-cohort-report')->weekly()->mondays()->at('09:00')->timezone('Europe/Madrid');

View File

@ -0,0 +1,135 @@
<?php
use App\Enums\DripEmailType;
use App\Jobs\Drip\SendAiConsentFollowUpEmailJob;
use App\Mail\Drip\AiConsentFollowUpEmail;
use App\Models\AiConsent;
use App\Models\User;
use App\Models\UserMailLog;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Mail;
/**
* Creates a user whose AI consent was recorded $acceptedDaysAgo days ago, having
* signed up $signedUpDaysAgo days ago.
*/
function userWithConsent(int $signedUpDaysAgo, int $acceptedDaysAgo): User
{
$user = User::factory()->create(['created_at' => now()->subDays($signedUpDaysAgo)]);
$user->aiConsents()->create([
'scope' => AiConsent::SCOPE_FINANCE,
'version' => (string) config('ai_suggestions.consent_version'),
'accepted_at' => now()->subDays($acceptedDaysAgo),
]);
return $user;
}
it('queues the follow-up for a post-onboarding consent accepted two days ago', function () {
Bus::fake();
$user = userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 2);
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
Bus::assertDispatched(
SendAiConsentFollowUpEmailJob::class,
fn (SendAiConsentFollowUpEmailJob $job): bool => $job->user->is($user),
);
});
it('skips consent given during onboarding', function () {
Bus::fake();
// Signed up and consented the same day → onboarding, not a deliberate opt-in.
userWithConsent(signedUpDaysAgo: 2, acceptedDaysAgo: 2);
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class);
});
it('treats consent exactly three days after signup as onboarding', function () {
Bus::fake();
// accepted two days ago, signed up five days ago → exactly 3 days apart (not > 3).
userWithConsent(signedUpDaysAgo: 5, acceptedDaysAgo: 2);
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class);
});
it('does not queue when the consent was not accepted exactly two days ago', function () {
Bus::fake();
userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 1);
userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 3);
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class);
});
it('skips revoked consent', function () {
Bus::fake();
$user = userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 2);
$user->aiConsents()->update(['revoked_at' => now()]);
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class);
});
it('does nothing when drip emails are disabled', function () {
config(['mail.drip_emails_enabled' => false]);
Bus::fake();
userWithConsent(signedUpDaysAgo: 10, acceptedDaysAgo: 2);
$this->artisan('email:ai-consent-follow-up')->assertSuccessful();
Bus::assertNotDispatched(SendAiConsentFollowUpEmailJob::class);
});
it('sends the email once and records a mail log', function () {
Mail::fake();
$user = User::factory()->has(AiConsent::factory(), 'aiConsents')->create();
(new SendAiConsentFollowUpEmailJob($user))->handle();
Mail::assertQueued(AiConsentFollowUpEmail::class);
expect($user->hasReceivedEmail(DripEmailType::AiConsentFollowUp))->toBeTrue();
expect(UserMailLog::where('user_id', $user->id)->count())->toBe(1);
});
it('renders the email in the user locale', function () {
app()->setLocale('es');
$user = User::factory()->make(['name' => 'Ada']);
$html = (new AiConsentFollowUpEmail($user))->render();
app()->setLocale('en');
expect($html)->toContain('Tu asistente de IA está trabajando duro');
expect($html)->toContain('category_source=ai');
});
it('does not resend if the user already received it', function () {
Mail::fake();
$user = User::factory()->has(AiConsent::factory(), 'aiConsents')->create();
UserMailLog::create([
'user_id' => $user->id,
'email_type' => DripEmailType::AiConsentFollowUp,
'email_identifier' => DripEmailType::AiConsentFollowUp->value,
'sent_at' => now(),
]);
(new SendAiConsentFollowUpEmailJob($user))->handle();
Mail::assertNotQueued(AiConsentFollowUpEmail::class);
});