Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions lib/emails/__tests__/validateSendEmailBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -182,15 +195,15 @@ 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);
});

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);
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 22 additions & 13 deletions lib/emails/validateSendEmailBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof sendEmailBodySchema>;

Expand Down
Loading