Files
jorgecuadros-platform/docs/MASS_EMAIL_NOTIFICATIONS.md
T
rmancinasandClaude Opus 5 f4b92fa7a5 fix(deploy): pass SES config through to the app stack
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>
2026-08-02 10:59:45 -07:00

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 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) 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 §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.