whisper-money/app/Console/Commands
Víctor Falcón 4ba78e54d8
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.
2026-08-12 11:29:28 +02:00
..
Concerns
…
AgentDatabaseCommand.php
…
ApplyRealEstateRevaluationCommand.php
…
BackfillAccountIbans.php
…
BackfillSpaces.php
…
BackfillTransactionDescriptions.php fix(banking): strip the ISO 20022 remittance tag from transaction descriptions (#744) 2026-08-09 18:11:05 +02:00
BackfillUserCurrencyCode.php
…
CancelFreeEnableBankingConnectionsCommand.php
…
CategorizeBackfillCommand.php
…
CheckBankLogosCommand.php
…
CrapCommand.php ci: add duplication and complexity quality checks (#765) 2026-08-11 13:29:37 +02:00
DeleteAiConsentCommand.php feat(ai): add command to delete a user's AI consent by email (#729) 2026-07-23 08:35:28 +00:00
DeleteEncryptedDataAccountsCommand.php
…
DeleteManualAccountDataCommand.php
…
DeleteUserCommand.php
…
DisconnectBankingConnectionsCommand.php
…
E2eBankingFixtureCommand.php
…
EnsureLaunchCouponsCommand.php
…
FeatureDisableCommand.php
…
FeatureEnableCommand.php
…
GenerateBudgetPeriods.php
…
GenerateMonthlyLoanBalances.php
…
GenerateStripePromotionCodesCommand.php chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
McpUsageStatsCommand.php feat(mcp): record MCP tool usage and report it with stats:mcp-usage (#760) 2026-08-11 10:27:32 +02:00
NotifyEncryptedDataRemovalCommand.php
…
ReassignLabeledBudgetTransactions.php fix(budgets): keep labeled expenses out of the catch-all budget (#781) 2026-08-11 15:45:19 +00:00
ResendSyncCommand.php
…
ResetDemoAccountCommand.php fix(demo): stop demo:reset from colliding on the fake Stripe subscription id (#756) 2026-08-10 12:48:13 +00:00
ResetUserCategories.php
…
ReviewIntegrationRequestsCommand.php
…
SendAiCohortReportCommand.php feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
SendAiConsentFollowUpEmailsCommand.php
…
SendDailyStatsReportCommand.php feat(stats): post the Discord stats reports in Spanish, opened by an AI summary (#752) 2026-08-10 10:13:40 +02:00
SendPaywallFollowUpEmailsCommand.php
…
SendStuckCohortReportCommand.php
…
SendSubscriptionCancelledEmailCommand.php
…
SendSubscriptionFunnelReportCommand.php feat(subscriptions): end the trial experiment and make the trial length per plan (#762) 2026-08-12 10:59:55 +02:00
SendTestEmails.php
…
SendUpdateEmailCommand.php
…
SendUserEmailsReportCommand.php feat(reports): email a monthly CSV of active user emails to the owners (#783) 2026-08-12 11:29:28 +02:00
SetBankLogoCommand.php chore(deps): update composer dependencies to latest (#764) 2026-08-11 11:15:27 +00:00
SetupMainUser.php
…
StripeSubscriptionStatsCommand.php
…
SuggestRulesCommand.php
…
SyncBankingConnections.php
…
SyncStripePricesCommand.php
…
TestScheduledDripEmailCommand.php
…