From 4ba78e54d8dfed546bf7edde5f85aa781239b7c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 12 Aug 2026 11:29:28 +0200 Subject: [PATCH] feat(reports): email a monthly CSV of active user emails to the owners (#783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Once a month, email a CSV with the email address of every non-deleted user to the owners. - New `email:user-emails-report` command builds the CSV and sends `UserEmailsReportEmail` with it attached as `text/csv`. - Scheduled `monthlyOn(1, '09:05')` in `Europe/Madrid`, next to the other `email:*` jobs. - Recipients come from a comma-separated `REPORT_RECIPIENTS` env var. The command fails loudly (exit 1, nothing sent) when it is unset, rather than silently skipping. The `SoftDeletes` global scope on `User` already excludes deleted users, so no extra `whereNull` is needed. ## ⚠️ Required before this ships Set `REPORT_RECIPIENTS` in the production environment, or the scheduled command will fail every month: ``` REPORT_RECIPIENTS=first@example.com,second@example.com ``` Values are trimmed and empty entries dropped, so trailing commas and spaces are safe. ## QA No UI surface, so this was QA'd the way it is actually used: running the command against the real local database (2520 users, 85 soft-deleted) with mail captured by Mailhog. | Check | Result | | --- | --- | | Command output | `Sent 2435 user email(s) as user-emails-2026-08-12.csv.` (2520 − 85) | | Message | 1 email, both recipients on a single `To` | | Subject | `Monthly user emails export: 2435 users` | | Attachment | one `text/csv` part, `filename=user-emails-2026-08-12.csv`, 58 KB | | CSV contents | `email` header + 2435 rows | | Soft-deleted leakage | 0 overlap with the 85 soft-deleted addresses | | Set equality | 0 rows in the CSV missing from the active set, 0 active users missing from the CSV | | Body | renders correctly in both the text and HTML parts | | Schedule | `schedule:list` → `5 7 1 * *` (07:05 UTC = 09:05 CEST), next due Sept 1 | | Missing `REPORT_RECIPIENTS` | errors, exit 1, nothing sent | | Recipient parsing | `" one@example.com , ,two@example.com,"` → two clean recipients | Tests: 2136 pass. `pint`, `phpstan`, `jscpd`, `crap`, `prettier` and `eslint` all clean. ## Review notes Two findings from review were raised rather than coded, since they are product calls: - **The export is not filtered by verification or consent.** It contains every active address, including ~5.8% unverified ones. Literal "all users", but those would bounce if the list is imported into a mail tool. There is no marketing-consent flag anywhere in the schema, so nothing is being ignored — just don't assume the list is filtered. - **Privacy posture.** This puts the full user-email list into two mailboxes every month, indefinitely, with no retention control. Worth a conscious decision for an app positioned on not sharing user data. `demo@whisper.money` is intentionally **not** excluded: the request was every non-deleted user, and the existing exclusion precedent protects the demo account from deletion, which is a different motive. CSV formula injection (`=`, `+`, `-`, `@` local parts evaluating on import into Sheets) was considered and skipped: zero such addresses exist today and the only sensitive payload is the list itself, which the recipients already own. --- .env.example | 1 + .../Commands/SendUserEmailsReportCommand.php | 64 +++++++++++++++++++ app/Mail/UserEmailsReportEmail.php | 49 ++++++++++++++ config/mail.php | 8 +++ .../views/mail/user-emails-report.blade.php | 10 +++ routes/console.php | 1 + .../SendUserEmailsReportCommandTest.php | 60 +++++++++++++++++ tests/Feature/MailSenderTest.php | 3 + 8 files changed, 196 insertions(+) create mode 100644 app/Console/Commands/SendUserEmailsReportCommand.php create mode 100644 app/Mail/UserEmailsReportEmail.php create mode 100644 resources/views/mail/user-emails-report.blade.php create mode 100644 tests/Feature/Console/SendUserEmailsReportCommandTest.php 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 () {