The stack env is assembled from Gitea repo secrets by the deploy workflows' `env_data` block — there is no .env file on the host for the app stack. SES was in neither, so `MailService` came up unconfigured on every deployment and, with NODE_ENV=production killing the stdout dev fallback, every notification and renewal aviso failed. Wire SES_REGION / SES_FROM / SES_FROM_NAME / SES_ACCESS_KEY / SES_SECRET_KEY / SES_CONFIGURATION_SET / NOTIFICATION_ADMIN_EMAILS through both galactus and cubex. No `_GALACTUS` suffix: one SES identity serves every deployment. Kept out of the required-secrets preflight — mail is not needed to boot, and failing a deploy over it would be wrong. Preflight warns instead, since the failure is otherwise invisible until someone clicks "Ejecutar". Also corrects the comments added in the previous commit, which claimed these belonged in a host env file rather than in CI secrets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
8.9 KiB
Markdown
191 lines
8.9 KiB
Markdown
# 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.
|