From b64728b3696c107112f4b280635d173aced125ba Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 30 Jun 2026 18:47:33 -0500 Subject: [PATCH] fix(emails): reject empty/unparseable POST /api/emails bodies (no silent 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) --- .../__tests__/validateSendEmailBody.test.ts | 37 +++++++++++++------ lib/emails/validateSendEmailBody.ts | 35 +++++++++++------- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/lib/emails/__tests__/validateSendEmailBody.test.ts b/lib/emails/__tests__/validateSendEmailBody.test.ts index 9fdb86ee..07b0f512 100644 --- a/lib/emails/__tests__/validateSendEmailBody.test.ts +++ b/lib/emails/__tests__/validateSendEmailBody.test.ts @@ -49,7 +49,7 @@ describe("validateSendEmailBody", () => { disallowed: ["stranger@example.com"], }); const request = createRequest( - { to: ["stranger@example.com"], subject: "Hi" }, + { to: ["stranger@example.com"], subject: "Hi", text: "body" }, { "x-api-key": "test-api-key" }, ); const result = await validateSendEmailBody(request); @@ -64,7 +64,7 @@ describe("validateSendEmailBody", () => { it("checks to + cc together against the authenticated account", async () => { const request = createRequest( - { to: ["a@example.com"], cc: ["b@example.com"], subject: "Hi" }, + { to: ["a@example.com"], cc: ["b@example.com"], subject: "Hi", text: "body" }, { "x-api-key": "test-api-key" }, ); await validateSendEmailBody(request); @@ -89,13 +89,21 @@ describe("validateSendEmailBody", () => { } }); - it("defaults to `Message from Recoup` when subject and body are empty", async () => { + it("returns 400 when neither html nor text is provided (no empty footer-only sends)", async () => { const request = createRequest({ to: ["d@example.com"] }, { "x-api-key": "k" }); const result = await validateSendEmailBody(request); - expect("data" in result).toBe(true); - if ("data" in result) { - expect(result.data.subject).toBe("Message from Recoup"); - } + expect("error" in result).toBe(true); + if ("error" in result) expect(result.error.status).toBe(400); + }); + + it("returns 400 when html is whitespace-only and no text is provided", async () => { + const request = createRequest( + { to: ["d@example.com"], subject: "Hi", html: " " }, + { "x-api-key": "k" }, + ); + const result = await validateSendEmailBody(request); + expect("error" in result).toBe(true); + if ("error" in result) expect(result.error.status).toBe(400); }); }); @@ -143,7 +151,12 @@ describe("validateSendEmailBody", () => { it("passes account_id override through to validateAuthContext", async () => { const request = createRequest( - { to: ["d@example.com"], subject: "s", account_id: "550e8400-e29b-41d4-a716-446655440000" }, + { + to: ["d@example.com"], + subject: "s", + account_id: "550e8400-e29b-41d4-a716-446655440000", + text: "body", + }, { "x-api-key": "org-key" }, ); await validateSendEmailBody(request); @@ -171,7 +184,7 @@ describe("validateSendEmailBody", () => { { email: "owner@example.com" }, { email: "owner.alt@example.com" }, ]); - const request = createRequest({ subject: "s" }, { "x-api-key": "k" }); + const request = createRequest({ subject: "s", text: "body" }, { "x-api-key": "k" }); const result = await validateSendEmailBody(request); expect("data" in result).toBe(true); @@ -182,7 +195,7 @@ describe("validateSendEmailBody", () => { it("returns 400 when 'to' is omitted and the account has no email on file", async () => { mockSelectAccountEmails.mockResolvedValue([]); - const request = createRequest({ subject: "s" }, { "x-api-key": "k" }); + const request = createRequest({ subject: "s", text: "body" }, { "x-api-key": "k" }); const result = await validateSendEmailBody(request); expect("error" in result).toBe(true); if ("error" in result) expect(result.error.status).toBe(400); @@ -190,7 +203,7 @@ describe("validateSendEmailBody", () => { it("does not resolve account emails when 'to' is provided", async () => { const request = createRequest( - { to: ["dest@example.com"], subject: "s" }, + { to: ["dest@example.com"], subject: "s", text: "body" }, { "x-api-key": "k" }, ); await validateSendEmailBody(request); @@ -221,7 +234,7 @@ describe("validateSendEmailBody", () => { mockValidateAuthContext.mockResolvedValue( NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), ); - const request = createRequest({ to: ["d@example.com"], subject: "s" }); + const request = createRequest({ to: ["d@example.com"], subject: "s", text: "body" }); const result = await validateSendEmailBody(request); expect("error" in result).toBe(true); if ("error" in result) expect(result.error.status).toBe(401); diff --git a/lib/emails/validateSendEmailBody.ts b/lib/emails/validateSendEmailBody.ts index 02b908e5..3b88a94c 100644 --- a/lib/emails/validateSendEmailBody.ts +++ b/lib/emails/validateSendEmailBody.ts @@ -7,19 +7,28 @@ import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmai import { readRawBody } from "@/lib/networking/readRawBody"; import { z } from "zod"; -export const sendEmailBodySchema = z.object({ - to: z - .array(z.string().email("each 'to' entry must be a valid email")) - .min(1, "to must include at least one recipient") - .optional(), - cc: z.array(z.string().email("each 'cc' entry must be a valid email")).default([]).optional(), - subject: z.string().optional(), - text: z.string().optional(), - html: z.string().default("").optional(), - headers: z.record(z.string(), z.string()).default({}).optional(), - chat_id: z.string().optional(), - account_id: z.string().uuid("account_id must be a valid UUID").optional(), -}); +export const sendEmailBodySchema = z + .object({ + to: z + .array(z.string().email("each 'to' entry must be a valid email")) + .min(1, "to must include at least one recipient") + .optional(), + cc: z.array(z.string().email("each 'cc' entry must be a valid email")).default([]).optional(), + subject: z.string().optional(), + text: z.string().optional(), + html: z.string().optional(), + headers: z.record(z.string(), z.string()).default({}).optional(), + chat_id: z.string().optional(), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), + }) + // Guard: never send an empty/footer-only email. A malformed or empty body + // parses to `{}` (readRawBody -> JSON.parse, `{}` on failure), which has + // neither field — so both that and an explicit body-less request fail here and + // return 400 instead of silently sending "Message from Recoup" + footer. + .refine(data => Boolean(data.html?.trim() || data.text?.trim()), { + message: "a non-empty html or text body is required", + path: ["html"], + }); export type SendEmailBody = z.infer;