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:
parent
b6bc7756bc
commit
4ba78e54d8
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue