Skip to content

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

Merged
sweetmantech merged 1 commit into
mainfrom
chore/email-api-guard
Jun 30, 2026
Merged

fix(emails): reject empty/unparseable POST /api/emails bodies (no silent footer-only sends)#729
sweetmantech merged 1 commit into
mainfrom
chore/email-api-guard

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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 html200
    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.

Written for commit b64728b. Summary will update on new commits.

Review in cubic

@vercel

vercel Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jun 30, 2026 11:48pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5e2ff11a-c2b8-4add-ab73-abeca01325c5

📥 Commits

Reviewing files that changed from the base of the PR and between 61ed2f7 and b64728b.

⛔ Files ignored due to path filters (1)
  • lib/emails/__tests__/validateSendEmailBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (1)
  • lib/emails/validateSendEmailBody.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/email-api-guard

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.
Architecture diagram
sequenceDiagram
    participant Client
    participant Route as POST /api/emails
    participant Validator as validateSendEmailBody()
    participant Parser as safeParseJson
    participant Schema as sendEmailBodySchema (Zod)

    Note over Client,Schema: NEW: Reject empty/unparseable bodies (no silent footer-only sends)

    Client->>Route: POST /api/emails<br/>with JSON body
    Route->>Validator: validate request + auth
    Validator->>Parser: parse body JSON
    alt Malformed JSON
        Parser-->>Validator: {} (unchanged contract)
    else Valid JSON
        Parser-->>Validator: parsed payload
    end
    Validator->>Schema: validate parsed body
    Schema->>Schema: .refine(): non-empty html|text after trim()?
    alt BOTH html and text empty/missing (incl. whitespace-only)
        Note over Schema: Guard rejects empty/footer-only
        Schema-->>Validator: ZodError: "a non-empty html or text body is required"
        Validator-->>Route: 400 Bad Request
        Route-->>Client: 400 + error message
    else html or text is non-empty
        Schema-->>Validator: validated SendEmailBody
        Validator-->>Route: valid payload + auth
        Route-->>Client: 200 + success
    end
Loading

Auto-approved: Tightens email validation to reject empty/unparseable bodies, preventing silent footer-only sends. Low-risk schema change with updated tests.

Re-trigger cubic

…ent footer-only sends)

Guard sendEmailBodySchema with a refine requiring a non-empty html or text body,
and drop the html "" default. A malformed/empty body parses to {} and now returns
400 instead of silently sending a "Message from Recoup" footer-only email.

Rebased onto main after #731 (email_send_log observability) rewrote
validateSendEmailBody: reconciled to the new { rawBody, error } | { rawBody, data }
return shape and the readRawBody read path (comment updated; safeParseJson no
longer used here). With #731 in place, a guarded 400 is also recorded as a
`rejected` row in email_send_log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Rebased onto main + preview-verified — b64728b3

Rebased onto main after api#731 merged (it rewrote validateSendEmailBody). Reconciled to #731's { rawBody, error } | { rawBody, data } return shape and the readRawBody read path; updated the guard comment (no longer references safeParseJson, which this file dropped). mergeable is now clean. Full lib/emails suite green (151), tsc + eslint clean.

Preview https://api-j5e6b3r3b-recoup.vercel.app (built from b64728b3). The guard runs before auth, so the rejection cases are token-free:

# Request Auth HTTP result
1 {to, subject} — no html/text none 400 a non-empty html or text body is required (missing_fields:["html"]) guard rejects
2 html:" " (whitespace), no text none 400 same guard rejects
3 not json … (unparseable) none 400 same (parses to {}) guard rejects
4 valid html body none 401 auth guard passed
5 valid html body, to omitted Bearer 200 sent to sweetmantech@gmail.com, id 9822160e… guard allows real sends

Synergy with #731 confirmed in email_send_log — every rejection is now a logged rejected row with the full raw_body (cases 1–4), and the unparseable not json … body is captured verbatim (15 B). Case 5 logged sent with matching resend_id. All 5 test rows deleted afterward.

Net effect: the empty footer-only "Message from Recoup" send is now a 400 + an observable rejected row instead of a silent delivery.

@sweetmantech sweetmantech merged commit f8d5dc2 into main Jun 30, 2026
5 checks passed
sweetmantech added a commit that referenced this pull request Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant