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>
8.9 KiB
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 and
docs/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 theStorageServiceenv-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 + aPOST /notifications/{slug}HTTP endpoint gated on the newnotification:sendability (MANAGER).packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql— two new tables (email_notification_log,account_status_history) with enums and FKs tocustomers.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_GATEuntil 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) 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 theCUSTOMERS+TRUSTslice. - Pólizas (
renewal:send) — pending avisos and the manual sweep. Reads thePOLICIESslice.
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
bodySnapshotand rendering HTML in the browser, deferred until a customer-service need surfaces. - Bounce / complaint webhooks.
providerMessageIdis 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.
Customerhas no language preference. Add one when the need is concrete (same open question asINSURANCE_FEATURES_SPEC.md§1.6). - Importing the legacy
utility_dbo.email_alert_logrows. They reference the oldNUMid(a stringified double) which no longer maps to a unified customer; an import would be destructive.