diff --git a/.env.example b/.env.example index 3d375dba..bf622800 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,7 @@ MAIL_FROM_NAME="Whisper Money" MAIL_DRIP_FROM_ADDRESS="hi@whisper.money" MAIL_DRIP_FROM_NAME="Álvaro and Víctor" ADMIN_EMAIL= +REPORT_RECIPIENTS= # Resend contact sync (optional, not used for sending email) RESEND_API_KEY= diff --git a/app/Console/Commands/SendUserEmailsReportCommand.php b/app/Console/Commands/SendUserEmailsReportCommand.php new file mode 100644 index 00000000..b2dba59a --- /dev/null +++ b/app/Console/Commands/SendUserEmailsReportCommand.php @@ -0,0 +1,64 @@ + $recipients */ + $recipients = config('mail.report_recipients'); + + if ($recipients === []) { + $this->error('REPORT_RECIPIENTS is not configured. Skipping the user emails report.'); + + return self::FAILURE; + } + + // The SoftDeletes global scope already leaves deleted users out. + $emails = User::query()->orderBy('created_at')->pluck('email'); + + $fileName = 'user-emails-'.Carbon::now()->format('Y-m-d').'.csv'; + + Mail::to($recipients)->send(new UserEmailsReportEmail( + csv: $this->toCsv($emails->all()), + userCount: $emails->count(), + fileName: $fileName, + )); + + $this->info("Sent {$emails->count()} user email(s) as {$fileName}."); + + return self::SUCCESS; + } + + /** + * @param array $emails + */ + private function toCsv(array $emails): string + { + $handle = fopen('php://temp', 'r+'); + + // PHP 9 changes the default $escape, which would silently change our output. + fputcsv($handle, ['email'], escape: ''); + + foreach ($emails as $email) { + fputcsv($handle, [$email], escape: ''); + } + + rewind($handle); + $csv = (string) stream_get_contents($handle); + fclose($handle); + + return $csv; + } +} diff --git a/app/Mail/UserEmailsReportEmail.php b/app/Mail/UserEmailsReportEmail.php new file mode 100644 index 00000000..24929df9 --- /dev/null +++ b/app/Mail/UserEmailsReportEmail.php @@ -0,0 +1,49 @@ +userCount} users", + ); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.user-emails-report', + with: [ + 'userCount' => $this->userCount, + ], + ); + } + + /** + * @return array + */ + public function attachments(): array + { + return [ + Attachment::fromData(fn (): string => $this->csv, $this->fileName) + ->withMime('text/csv'), + ]; + } +} diff --git a/config/mail.php b/config/mail.php index 32ae41b1..30686c57 100644 --- a/config/mail.php +++ b/config/mail.php @@ -17,6 +17,14 @@ return [ 'admin_email' => env('ADMIN_EMAIL'), + /** + * Comma-separated recipients of the internal owner reports. + */ + 'report_recipients' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('REPORT_RECIPIENTS', '')), + ))), + /* |-------------------------------------------------------------------------- | Email Verification diff --git a/resources/views/mail/user-emails-report.blade.php b/resources/views/mail/user-emails-report.blade.php new file mode 100644 index 00000000..5248f959 --- /dev/null +++ b/resources/views/mail/user-emails-report.blade.php @@ -0,0 +1,10 @@ + +# Monthly user emails export + +The attached CSV lists the email address of every active (non-deleted) user. + +**Users:** {{ $userCount }} + +Thanks,
+{{ config('app.name') }} +
diff --git a/routes/console.php b/routes/console.php index 5a886f91..06951fba 100644 --- a/routes/console.php +++ b/routes/console.php @@ -10,6 +10,7 @@ Schedule::command('real-estate:apply-revaluation')->monthlyOn(1, '00:00'); Schedule::command('loans:generate-balances')->monthlyOn(1, '00: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('email:user-emails-report')->monthlyOn(1, '09:05')->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:subscription-funnel')->weekly()->mondays()->at('09:15')->timezone('Europe/Madrid'); diff --git a/tests/Feature/Console/SendUserEmailsReportCommandTest.php b/tests/Feature/Console/SendUserEmailsReportCommandTest.php new file mode 100644 index 00000000..f376008b --- /dev/null +++ b/tests/Feature/Console/SendUserEmailsReportCommandTest.php @@ -0,0 +1,60 @@ + ['owner-one@example.com', 'owner-two@example.com']]); +}); + +test('command emails a csv with every non-deleted user email', function () { + Mail::fake(); + + User::factory()->create(['email' => 'first@example.com', 'created_at' => now()->subDay()]); + User::factory()->create(['email' => 'second@example.com', 'created_at' => now()]); + User::factory()->create(['email' => 'deleted@example.com'])->delete(); + + artisan('email:user-emails-report') + ->expectsOutputToContain('Sent 2 user email(s)') + ->assertSuccessful(); + + $fileName = 'user-emails-'.now()->format('Y-m-d').'.csv'; + + Mail::assertSent(UserEmailsReportEmail::class, function (UserEmailsReportEmail $mail) use ($fileName) { + $mail->assertHasAttachedData("email\nfirst@example.com\nsecond@example.com\n", $fileName, ['mime' => 'text/csv']); + + return $mail->userCount === 2 + && $mail->fileName === $fileName + && $mail->hasTo('owner-one@example.com') + && $mail->hasTo('owner-two@example.com'); + }); +}); + +test('command still sends the report when there are no users', function () { + Mail::fake(); + + artisan('email:user-emails-report') + ->expectsOutputToContain('Sent 0 user email(s)') + ->assertSuccessful(); + + Mail::assertSent(UserEmailsReportEmail::class, function (UserEmailsReportEmail $mail) { + return $mail->userCount === 0 && $mail->csv === "email\n"; + }); +}); + +test('command fails loudly when no recipients are configured', function () { + Mail::fake(); + + config(['mail.report_recipients' => []]); + + User::factory()->create(); + + artisan('email:user-emails-report') + ->expectsOutputToContain('REPORT_RECIPIENTS is not configured.') + ->assertFailed(); + + Mail::assertNothingSent(); +}); diff --git a/tests/Feature/MailSenderTest.php b/tests/Feature/MailSenderTest.php index 39c9b64d..bc208544 100644 --- a/tests/Feature/MailSenderTest.php +++ b/tests/Feature/MailSenderTest.php @@ -13,6 +13,7 @@ use App\Mail\Drip\SubscriptionCancelledEmail; use App\Mail\Drip\WelcomeEmail; use App\Mail\EnableBankingConnectionsCancelledEmail; use App\Mail\UpdateEmail; +use App\Mail\UserEmailsReportEmail; use App\Models\BankingConnection; use App\Models\User; use App\Notifications\VerifyEmailNotification; @@ -128,6 +129,7 @@ test('default sender is used for active non-drip mailables', function (string $m BankingConnectionAuthFailedEmail::class => new BankingConnectionAuthFailedEmail($user, BankingConnection::factory()->for($user)->create(['aspsp_name' => 'Test Bank'])), EnableBankingConnectionsCancelledEmail::class => new EnableBankingConnectionsCancelledEmail($user, 2), BrokenBankLogosReportEmail::class => new BrokenBankLogosReportEmail([['id' => 'bank-1', 'name' => 'Test Bank', 'previous_logo' => 'https://example.com/logo.png']]), + UserEmailsReportEmail::class => new UserEmailsReportEmail("email\ntest@example.com\n", 1, 'user-emails-2026-01-01.csv'), }; sendWithArrayMailer($mailable); @@ -142,6 +144,7 @@ test('default sender is used for active non-drip mailables', function (string $m BankingConnectionAuthFailedEmail::class, EnableBankingConnectionsCancelledEmail::class, BrokenBankLogosReportEmail::class, + UserEmailsReportEmail::class, ]); test('transaction sync email envelope explicitly uses the default sender', function () {