# Mass Email Notifications

Modern replacement for the four PHP scripts under
`email.notifications/send*.php` that fired bulk emails off the legacy
`utility_dbo.email_alert_log` table. Lives in this codebase from
`massive-email-notification` onward; the PHP scripts stay operational
until the office flips over.

## Why

The legacy scripts did three things this app needed to keep doing: send
outstanding-payment reminders, send payment-confirmation letters, and
fire account-status alerts (red and yellow). They also sent a fourth
trust-payment confirmation tied to `TRUSTHFEE`. Each was a separate CGI
script the office hit manually or via cron, talking to `utility_dbo` over
the same `mysqli` connection as the rest of the portal.

The unified schema (see [`PLAN.md`](../PLAN.md) and
[`docs/INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md)) folded
`datosfreak` and `TRUSTHFEE` into `customers` + `transactions` +
`trust_accounts`, so the scripts' SQL no longer maps to anything. Rather
than maintain parallel sync code to keep `utility_dbo` populated, this
feature ports the four jobs onto the unified data and writes its own log.

## What ships

- `apps/api/src/mail/` — outbound mail transport. Amazon SES (matches
  the `StorageService` env-driven optional-client pattern). Dev falls
  back to stdout logging so a fresh checkout can exercise the jobs
  without SES credentials.
- `apps/api/src/notifications/` — the four jobs (`outstanding`,
  `payment-confirm`, `account-status`, `trust-confirm`), each a public
  service method + a `POST /notifications/{slug}` HTTP endpoint gated on
  the new `notification:send` ability (MANAGER).
- `apps/api/src/notifications/notification-schedule.service.ts` (+ its own
  module) — the cadence of **both** automatic sweeps, stored in
  `app_settings` and installed into `SchedulerRegistry` at boot. See
  "Scheduled runs" below.
- `packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql`
  — two new tables (`email_notification_log`, `account_status_history`)
  with enums and FKs to `customers`.
- `apps/web/src/app/notificaciones/` — admin page: a shared flags panel and
  schedule editor above the tabs, then per-tab trigger cards, a
  transport-status header, and a paginated log browser.

## Job semantics

Preserved from the PHP originals (see
`~/Documents/Claude-Memory/email-notifications-spec.md`):

| Job | Recipients | Subject | Response key |
|---|---|---|---|
| 1. Outstanding payments | Customers with ≥1 outstanding Transaction (amount<0) | "Jorge Cuadros - Outstanding Payments" | `result:"success", notificationType:"sendPaymentConfirmation"` |
| 2. Payment confirmation | Customers with a credit in last 24h | "Jorge Cuadros - Payment Confirmation" | `request:"success", notificationType:"sendPaymentConfirmation"` |
| 3. Account status | All customers with a balance; yellow/red thresholds | "Jorge Cuadros - Account Status Alert" | `request:"success", notificationType:"sendAccountStatus"` |
| 4. Trust confirmation | Customers with TrustAccount + recent TRUST-domain credit | "Jorge Cuadros - Trust Payment Confirmation" | `request:"success", notificationType:"sendTrustPaymentConfirmation"` |

Wire shapes match the PHP originals byte-for-byte so anything downstream
that scrapes `notificationType:"sendPaymentConfirmation"` keeps working.
Job 1 reports `result` (not `request`) and `notificationType` literally
`sendPaymentConfirmation` — these are the legacy quirks, preserved.

### Day gates (Job 3 only)

- **Yellow** ("DEBAJO DEL TIPO"): Wed only (or `ignoreDayRestriction`).
- **Red** ("EN ROJO"): Mon/Wed/Fri only (or `ignoreDayRestriction`).
- A customer who is red on Tuesday is logged as `SKIPPED_GATE` until
  Wed, when both checks can fire on the same row.

### Threshold logic (Job 3)

The PHP used `datosfreak.TIPO` (50/100/200/300/500) and a hardcoded
threshold table. The new schema encodes this as `Customer.minimumBalance`:

- Yellow: `0 ≤ balance < minimumBalance`
- Red: `balance < 0`

Per-currency balance uses `BillingService.balances()` semantics (signed
`SUM(transactions.amount)`, voided + outstanding excluded), so a
yellow/red alert always lines up with what the receivables worklist shows
staff. The customer-servicing letter reports in USD because the legacy
letter was always USD; the union of `balanceUsd` and `balanceMxn` is
reported per-customer, never collapsed (see `BillingService.balances()`).

### Rate limit (Job 3 only)

`useEmailLimit=true` enables a vestigial throttle: pause the sweep 1h
after 100 sends. Off by default; SES does not need it.

## Send flags

The three flags are **platform-wide**, not per-tab. They live in the
`/notificaciones` shell above the tabs (`NotificationFlagsCard`), and the
shell passes them to both halves.

| Flag | Applies to | Effect |
|---|---|---|
| `debug` | everything | Rewrites every recipient to `DEBUG_RECIPIENT` (`rmancinas@freakma.net`), tags the log row `debug: true`, sends with `xTracking: "debug"`. |
| `ignoreDayRestriction` | Job 3 only | Bypasses the Wed / Mon-Wed-Fri gates. |
| `useEmailLimit` | Job 3 only | The vestigial throttle above. |

`debug` used to exist only on the servicios side, which meant there was no
way to test a renewal aviso without mailing a real customer. On the pólizas
path it now does three things beyond diverting the mail, all for the same
reason — *the customer was not notified, so nothing may claim they were*:

1. no `RenewalNotice` row is written, so the aviso stays in the pending list;
2. the sweep's `lastSuccessfulAt` is not advanced, because `renewalWindow()`
   uses it to widen the window over missed days — advancing it after a test
   run would narrow tomorrow's window and drop those candidates for good;
3. the send response carries `debug: true` and the address actually used, so
   the UI says "prueba enviada … el cliente no ha recibido nada" rather than
   claiming a delivery.

Flags are **per-visit UI state and are never persisted.** A stored `debug`
would survive a reload and silently swallow real customer mail for as long
as nobody noticed. For the same reason the automatic runs below ignore them
entirely and always send for real.

## Tables

### `email_notification_log`

One row per send attempt (sent, failed, skipped). Carries the rendered
body verbatim so a customer reply quoting an old email can be traced to
the exact letter sent. SES MessageId stored for bounce/complaint
correlation.

Indexes: `(sendDate)`, `(notificationType, sendDate)`, `(customerId, sendDate)`.

**This table is not job-specific.** Insurance renewal avisos
(`RenewalsService`, see [`RENEWAL_NOTICES.md`](RENEWAL_NOTICES.md)) write
here too, as `notificationType = RENEWAL_NOTICE` /
`servicio = POLICIES` — one send history for the whole platform rather
than one per feature. `NotificationLogService` is the only writer;
anything that sends mail goes through it.

`level` is therefore per-type and cannot be read without its
`notificationType`: 0/1 (yellow/red) on `ACCOUNT_STATUS`, the aviso
generation 1/2/3 on `RENEWAL_NOTICE`, null elsewhere. On the web side
`notificationLevelLabel()` is the only place that branch lives.

Renewals keep their own `renewal_notices` row as well. The two are not
redundant: `renewal_notices` is *gating* state (one row per
policy+generation, "already notified" — it drives the pending list),
while this log is *history* (every attempt, including the failures and
no-email skips a gating row cannot represent).

### `account_status_history`

Mirrors the legacy `utility_dbo.send_account_status_history` table:
`(customerId, customerName, customerEmail, tipo, tCambio, balance,
solicitado, level)`. `tipo` is the literal `"DEBAJO DEL TIPO"` or
`"EN ROJO"` string the PHP used. `solicitado` keeps the legacy formula
(`0 - TIPO - BALANCE`) even though it double-subtracts; downstream
reports depend on the exact figure.

Indexes: `(sendDate)`, `(customerId, sendDate)`, `(level, sendDate)`.

## Environment

```
SES_REGION=us-east-1
SES_ACCESS_KEY=...
SES_SECRET_KEY=...
SES_FROM=mail@jorgecuadros.com
SES_FROM_NAME=Information Server
SES_CONFIGURATION_SET=...      # optional
NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net  # fallback only
```

`NOTIFICATION_ADMIN_EMAILS` is no longer the source of truth. The summary
recipients are edited in the UI and stored in `app_settings`; the env var
is the fallback for a deployment where nobody has saved them yet. See
"Operator settings" below.

Without SES_* the API still boots and `MailService` falls back to stdout
in dev (`NODE_ENV !== "production"`). In production every send throws
`ServiceUnavailableException` and the row is recorded as `FAILED`.

These are **runtime** config — read at container boot, never baked into the
image. For the Portainer deployments they are set as **Gitea repo secrets**
and injected into the stack env by the `env_data` block of
`.gitea/workflows/deploy-galactus.yml` (and `deploy.yml`), exactly like
`DATABASE_URL` and `SESSION_SECRET`. Unlike most secrets there they carry no
`_GALACTUS` suffix: one SES identity serves every deployment.

They are optional to *deploy* — the preflight only warns — but the
production image sets `NODE_ENV=production`, which disables the stdout dev
fallback, so a blank SES config makes every send fail loudly rather than
quietly going nowhere.

## UI

`/notificaciones`, two tabs over the one log.

Above the tabs, owned by the shell because both halves are subject to them:

- **Flags del envío** — the three flags above.
- **Programación de envíos** — the cadence of both automatic sweeps
  (`setting:manage` to edit; everyone can see when the next run is).

Then per tab:

- **Servicios** (`notification:send`) — an "Ejecutar todos" card, four
  trigger cards, a transport status header, and the summary-recipients
  setting. Reads the `CUSTOMERS` + `TRUST` slice.
- **Pólizas** (`renewal:send`) — pending avisos and the manual sweep.
  Reads the `POLICIES` slice.

Both render the same `NotificationLogPanel` ("Registro de envíos"), which
filters by servicio and by view (todos / enviados / fallidos / omitidos).
STAFF users see the Servicios log read-only.

Both mass actions ("Ejecutar todos" and the pólizas sweep) confirm before
firing **only when `debug` is off** — that is the case where real customers
receive mail, and a confirm on every click trains people to dismiss it.

## API surface

| Method | Route | Ability |
|---|---|---|
| `POST` | `/notifications/outstanding-payments` | `notification:send` |
| `POST` | `/notifications/payment-confirmation` | `notification:send` |
| `POST` | `/notifications/account-status` | `notification:send` |
| `POST` | `/notifications/trust-payment-confirmation` | `notification:send` |
| `POST` | `/notifications/run-all` | `notification:send` |
| `GET` | `/notifications/log`, `/notifications/stats` | authenticated |
| `GET` | `/notifications/settings/admin-emails` | authenticated |
| `PUT` | `/notifications/settings/admin-emails` | `setting:manage` |
| `GET` | `/notifications/settings/schedule` | authenticated |
| `PUT` | `/notifications/settings/schedule/:kind` | `setting:manage` |

Every trigger accepts the flags as **body or query string** — the PHP
scripts took both (STDIN vs HTTP-CGI) and parity was cheap. The pólizas
endpoints (`POST /renewals/sweep`, `POST /renewals/send`) accept `debug`
only; the other two flags are estado-de-cuenta concepts and are not
accepted there rather than being silently ignored.

## Operator settings

`app_settings` holds the configuration staff change without a redeploy.
`SettingsService` resolves every key **db → env → default**, and reports
which of the three a value came from so the UI can say so. Adding a key
means adding a typed accessor there, not a generic getter.

Keys today:

| Key | Edited on | Notes |
|---|---|---|
| `notification.adminEmails` | Servicios tab | Summary recipients, comma-separated. |
| `notification.schedule.servicios` | shell | JSON cadence of the automatic run-all. |
| `notification.schedule.polizas` | shell | JSON cadence of the renewal sweep. |

All three are gated on `setting:manage` (ADMIN — above `notification:send`,
because redirecting the audit summaries is how someone would stop them being
read).

`notification.adminEmails` is read on every job rather than cached, so an
edit takes effect on the next sweep with no restart. An empty saved list
means "nobody" and deliberately does **not** fall through to the env.

The two schedule keys have **no env rung** on the db → env → default ladder:
a cadence was never an environment variable (it was a `@Cron` literal in the
source), so the only two sources are the operator's row and the shipped
default. A row that fails to parse is logged and treated as absent — a bad
JSON blob must not take the scheduler down with it.

Credentials do not belong here. SES keys, `DATABASE_URL` and S3 config stay
in the environment: they are deployment identity, they must exist before
the app can reach its own database, and a table only widens who can read
them.

## Scheduled runs

Both halves run themselves on an **operator-editable** cadence. Nothing
about the schedule is in the source any more:

| Kind | Handler | Default | Was |
|---|---|---|---|
| `servicios` | `NotificationsService.scheduledRunAll()` → all four jobs in order | **off**, 07:00 Mon/Wed/Fri when enabled | nothing — the four jobs were click-only |
| `polizas` | `RenewalsService.scheduledSweep()` | **on**, 06:00 daily | `@Cron("0 6 * * *")` in `renewals.service.ts` |

The defaults preserve exactly what each half did before: pólizas keeps its
06:00 sweep, servicios stays off. A default that starts mailing 260
customers on its own after a deploy is not a default, it's an incident.

### How it works

`NotificationScheduleService` owns both cadences. The services that own the
sweeps register a handler in `onModuleInit`:

```ts
await this.schedule.register("polizas", () => this.scheduledSweep());
```

The schedule service then compiles the stored value to a cron expression
(`{hour, minute, weekdays}` → `m h * * dow`, empty weekdays = `*`) and
installs a `CronJob` in `SchedulerRegistry` under
`notification-schedule:<kind>`, in `America/Tijuana`. Saving from the UI
re-reads the row, removes the old job and installs the new one — **no
restart**, which was the whole point.

Handlers are registered rather than injected because
`NotificationsModule` and `RenewalsModule` both need this service and
neither may import the other. It lives in its own
`NotificationScheduleModule` for the same reason as
`NotificationLogModule`.

`cron` is a **direct dependency of `apps/api`**, not just a transitive one
of `@nestjs/schedule`: pnpm's strict layout does not hoist it, so
`import { CronJob } from "cron"` fails to resolve without it.

### What a scheduled run does not do

- **It never uses the UI flags.** No `debug` (so a forgotten test toggle
  cannot silently stop customer mail), and no `ignoreDayRestriction` — an
  automatic run on the operator's own cadence is exactly the case the
  Mon/Wed/Fri gate was written for, so bypassing it would mail the red list
  every scheduled day.
- **It does not guard against multi-replica double-fire.** The pólizas sweep
  has always had its own DB lock (`scheduled_job_states`, taken in
  `RenewalsService.acquireLock`); the servicios run-all has no equivalent
  and relies on the deployment being single-replica, which it is today on
  galactus. Adding one means the `OpsService` single-running-job pattern —
  a DB row, not an in-process flag.

## What is intentionally NOT in scope

- Per-recipient preview / HTML view in the UI. The log table shows what
  was sent; previewing one requires fetching `bodySnapshot` and rendering
  HTML in the browser, deferred until a customer-service need surfaces.
- Bounce / complaint webhooks. `providerMessageId` is captured so a future
  SNS topic can write back; the integration itself is a separate piece
  of work.
- Spanish / English body toggle. Legacy letters are English; the legacy
  customer base is bilingual. `Customer` has no language preference.
  Add one when the need is concrete (same open question as
  [`INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md) §1.6).
- Importing the legacy `utility_dbo.email_alert_log` rows. They reference
  the old `NUMid` (a stringified double) which no longer maps to a
  unified customer; an import would be destructive.
