# 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). - `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 with 4 trigger cards, a flags panel, 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. ## 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 ``` 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: - **Servicios** (`notification:send`) — four trigger cards, a debug/ignoreDayRestriction/useEmailLimit flags panel, a transport status header. 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. ## Cron (future) The four service methods (`runOutstandingPayments`, `runPaymentConfirmation`, `runAccountStatus`, `runTrustConfirmation`) are the entry points. A future `@nestjs/schedule` cron would call them on the legacy cadence (Job 3 on Mon/Wed/Fri, Job 2 daily, Jobs 1 + 4 ad-hoc). Pattern matches `OpsService`'s single-running-job guard: one `email_notification_sweep` OpsJob per run, with its log streamed to `OpsJob.log`. ## 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.