feat(reports): email a monthly CSV of active user emails to the owners (#783)

## 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.
This commit is contained in:
Víctor Falcón 2026-08-12 11:29:28 +02:00 committed by GitHub
parent b6bc7756bc
commit 4ba78e54d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 196 additions and 0 deletions

View File

@ -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=

View File

@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands;
use App\Mail\UserEmailsReportEmail;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
class SendUserEmailsReportCommand extends Command
{
protected $signature = 'email:user-emails-report';
protected $description = 'Email a CSV with every active user\'s email address to the owners';
public function handle(): int
{
/** @var array<int, string> $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<int, string> $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;
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class UserEmailsReportEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public string $csv,
public int $userCount,
public string $fileName,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "Monthly user emails export: {$this->userCount} users",
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.user-emails-report',
with: [
'userCount' => $this->userCount,
],
);
}
/**
* @return array<int, Attachment>
*/
public function attachments(): array
{
return [
Attachment::fromData(fn (): string => $this->csv, $this->fileName)
->withMime('text/csv'),
];
}
}

View File

@ -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

View File

@ -0,0 +1,10 @@
<x-mail::message>
# Monthly user emails export
The attached CSV lists the email address of every active (non-deleted) user.
**Users:** {{ $userCount }}
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

View File

@ -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');

View File

@ -0,0 +1,60 @@
<?php
use App\Mail\UserEmailsReportEmail;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use function Pest\Laravel\artisan;
beforeEach(function () {
config(['mail.report_recipients' => ['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();
});

View File

@ -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 () {