NOTIFICATION_ADMIN_EMAILS made "add Beto to the summaries" a redeploy — the wrong unit of work for a list that changes when office staff change. Adds `app_settings`, a key/value table for the configuration staff must be able to change without a deploy, and `SettingsService`, which resolves every key db -> env -> default and reports which of the three a value came from. That ladder is what makes the move safe: a deployment behaves exactly as before until somebody saves in the UI, and the screen can say "this is still coming from the deployment" rather than implying somebody chose it. - new ability `setting:manage` (ADMIN) — deliberately above `notification:send`, since redirecting the audit summaries is how someone would quietly stop them being read - GET/PUT /notifications/settings/admin-emails; read is open to any logged-in user so the UI can display the list, write is gated - resolved per job, not cached at boot, or we would reintroduce exactly the restart-to-apply behaviour being removed - a saved empty list means "nobody" and does NOT fall through to the env, or clearing the field would keep mailing the people just removed Credentials stay in env — see the model doc for where the line is drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 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 # 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:
- 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.
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.
Currently one key: notification.adminEmails (summary recipients), edited
on the Servicios tab, gated on setting:manage (ADMIN — above
notification:send, because redirecting the audit summaries is how someone
would stop them being read). 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.
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.
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.