fix(emails): reject empty/unparseable POST /api/emails bodies (no silent footer-only sends)
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-emptyhtmlORtext(aftertrim())
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/emailssuite: 147 passing. Lint clean. (tscshows pre-existing, unrelated errors inlib/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
sendEmailBodySchemawith a refine that requires non-emptyhtmlortext; removed thehtmldefault. - Malformed or empty JSON (via
readRawBody) now validates as{}and returns 400;safeParseJsonis no longer used here. - 400s are recorded in
email_send_logasrejected.
- Guarded
-
Migration
- Clients must send non-empty
htmlortext. - Empty strings, whitespace-only, or invalid JSON now return 400.
- Clients must send non-empty