fix(emails): reject empty/unparseable POST /api/emails bodies (no silent footer-only sends)

Merged
SW
4mo ago
+47-25

What

POST /api/emails accepted a body with no html/text and silently delivered a footer-only "Message from Recoup" email with success:true. A malformed JSON body hit the same path — safeParseJson returns {} on parse failure, and the all-optional schema accepted {}. In a 2026-06-30 production sample, ~38% of delivered report emails were empty footer-only this way (incl. to a WMG label exec).

Change

Tighten sendEmailBodySchema in lib/emails/validateSendEmailBody.ts:

  • drop html: z.string().default("")
  • add a .refine() requiring a non-empty html OR text (after trim())

Malformed (→ {}) and empty bodies now fail validation → 400, instead of sending.

Deliberately NOT changed: safeParseJson (it has ~30 callers — changing its {}-on-failure contract is out of scope/risky), and resolveEmailSubject (it keeps deriving the subject from the body's first line; with a body now required, the generic "Message from Recoup" default no longer ships an empty email).

TDD

  • Flipped the existing test that asserted the empty-body subject-default to expect 400 (it encoded the bug).
  • Added whitespace-only-html and missing-body cases — confirmed RED before GREEN.
  • Updated fixtures that relied on the permissive behavior to carry a real body.
  • Full lib/emails suite: 147 passing. Lint clean. (tsc shows pre-existing, unrelated errors in lib/trigger/__tests__ Trigger.dev mocks — not touched here.)

Verify (maps to the issue's REPRO-1/2/3)

  • REPRO-1 malformed JSON → 400 ✓ (unit: parsed {} → refine fails)
  • REPRO-2 valid {"html":""} → 400 ✓
  • REPRO-3 valid real html → 200 ✓
    Preview verification against the deployed URL to follow as a comment once the deployment is Ready.

Docs

Contract gains a 400 for empty/unparseable bodies — paired docs PR to add it to the /api/emails OpenAPI (docs → api order).

Part of recoupable/chat#1829 (task-email pipeline). Base test per repo convention.

🤖 Generated with Claude Code


Summary by cubic

Rejects empty or unparseable POST /api/emails bodies to stop footer-only sends. Requests now return 400 unless html or text has non-whitespace content, and rejections are logged as rejected in email_send_log.

  • Bug Fixes

    • Guarded sendEmailBodySchema with a refine that requires non-empty html or text; removed the html default.
    • Malformed or empty JSON (via readRawBody) now validates as {} and returns 400; safeParseJson is no longer used here.
    • 400s are recorded in email_send_log as rejected.
  • Migration

    • Clients must send non-empty html or text.
    • Empty strings, whitespace-only, or invalid JSON now return 400.