diff --git a/lib/emails/__tests__/sendEmailHandler.test.ts b/lib/emails/__tests__/sendEmailHandler.test.ts index 3b5fa6e8..e488e45c 100644 --- a/lib/emails/__tests__/sendEmailHandler.test.ts +++ b/lib/emails/__tests__/sendEmailHandler.test.ts @@ -4,6 +4,8 @@ import { sendEmailHandler } from "../sendEmailHandler"; const mockValidateSendEmailBody = vi.fn(); const mockProcessAndSendEmail = vi.fn(); +const mockEnsureCredits = vi.fn(); +const mockRecordCreditDeduction = vi.fn(); vi.mock("@/lib/emails/validateSendEmailBody", () => ({ validateSendEmailBody: (...args: unknown[]) => mockValidateSendEmailBody(...args), @@ -13,6 +15,18 @@ vi.mock("@/lib/emails/processAndSendEmail", () => ({ processAndSendEmail: (...args: unknown[]) => mockProcessAndSendEmail(...args), })); +vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ + ensureCreditsOrShortCircuit: (...args: unknown[]) => mockEnsureCredits(...args), +})); + +vi.mock("@/lib/credits/recordCreditDeduction", () => ({ + recordCreditDeduction: (...args: unknown[]) => mockRecordCreditDeduction(...args), +})); + +vi.mock("@/lib/credits/const", () => ({ + CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL: "https://chat.recoupable.com/credits", +})); + vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); @@ -41,6 +55,51 @@ describe("sendEmailHandler", () => { message: "Email sent successfully.", id: "resend-id-1", }); + mockEnsureCredits.mockResolvedValue(null); // credits available → proceed + mockRecordCreditDeduction.mockResolvedValue({ success: true }); + }); + + it("gates on credits then charges 1 credit on a successful send", async () => { + const response = await sendEmailHandler(createRequest()); + expect(response.status).toBe(200); + expect(mockEnsureCredits).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "account-123", creditsToDeduct: 1 }), + ); + expect(mockRecordCreditDeduction).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "account-123", + creditsToDeduct: 1, + source: "api", + modelId: "POST /api/emails", + }), + ); + }); + + it("returns the 402 short-circuit and does not send when credits are insufficient", async () => { + mockEnsureCredits.mockResolvedValue( + NextResponse.json({ status: "error", error: "Insufficient credits" }, { status: 402 }), + ); + const response = await sendEmailHandler(createRequest()); + expect(response.status).toBe(402); + expect(mockProcessAndSendEmail).not.toHaveBeenCalled(); + expect(mockRecordCreditDeduction).not.toHaveBeenCalled(); + }); + + it("does not charge when the send fails", async () => { + mockProcessAndSendEmail.mockResolvedValue({ success: false, error: "resend boom" }); + const response = await sendEmailHandler(createRequest()); + expect(response.status).toBe(502); + expect(mockRecordCreditDeduction).not.toHaveBeenCalled(); + }); + + it("returns a controlled 500 with CORS when the credit gate throws", async () => { + mockEnsureCredits.mockRejectedValue(new Error("stripe down")); + const response = await sendEmailHandler(createRequest()); + expect(response.status).toBe(500); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + const json = await response.json(); + expect(json.status).toBe("error"); + expect(mockProcessAndSendEmail).not.toHaveBeenCalled(); }); it("sends to the validated recipients and maps chat_id to the footer link", async () => { diff --git a/lib/emails/sendEmailHandler.ts b/lib/emails/sendEmailHandler.ts index 006bd002..3ea92020 100644 --- a/lib/emails/sendEmailHandler.ts +++ b/lib/emails/sendEmailHandler.ts @@ -2,6 +2,22 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateSendEmailBody } from "@/lib/emails/validateSendEmailBody"; import { processAndSendEmail } from "@/lib/emails/processAndSendEmail"; +import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortCircuit"; +import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; +import { CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL } from "@/lib/credits/const"; + +/** + * Credits charged per email sent. 1 credit = $0.01. Resend's per-email cost is + * ≤ $0.0004 (cheapest paid tier: Pro $20 / 50,000 emails), which rounds up to + * the $0.01 minimum — so we charge 1 credit, no markup. + */ +export const EMAIL_CREDIT_COST = 1; + +/** + * Stamped onto the `usage_events.model_id` for each send so endpoint usage is + * queryable: `select count(*) from usage_events where model_id = 'POST /api/emails'`. + */ +export const EMAIL_USAGE_MODEL_ID = "POST /api/emails"; /** * Handler for POST /api/emails. @@ -13,36 +29,69 @@ import { processAndSendEmail } from "@/lib/emails/processAndSendEmail"; * Body validation, auth, and the recipient restriction all live in * `validateSendEmailBody`. * + * Charges `EMAIL_CREDIT_COST` credits: gate first (402 if the account can't + * cover it; auto-recharges via a card on file), then deduct only on a + * successful send (atomic `credits_usage` + `usage_events` via + * `recordCreditDeduction`). + * * @param request - The request object. * @returns A NextResponse with the send result. */ export async function sendEmailHandler(request: NextRequest): Promise { - const validated = await validateSendEmailBody(request); - if (validated instanceof NextResponse) { - return validated; - } + try { + const validated = await validateSendEmailBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { to, cc = [], subject, text, html = "", headers = {}, chat_id, accountId } = validated; + + const short = await ensureCreditsOrShortCircuit({ + accountId, + creditsToDeduct: EMAIL_CREDIT_COST, + successUrl: CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL, + }); + if (short) { + return short; + } + + const result = await processAndSendEmail({ + to, + cc, + subject, + text, + html, + headers, + room_id: chat_id, + }); - const { to, cc = [], subject, text, html = "", headers = {}, chat_id } = validated; + if (result.success === false) { + // No charge — credits are deducted only on a successful send. + return NextResponse.json( + { status: "error", error: result.error }, + { status: 502, headers: getCorsHeaders() }, + ); + } - const result = await processAndSendEmail({ - to, - cc, - subject, - text, - html, - headers, - room_id: chat_id, - }); + // Charge on success (best-effort: recordCreditDeduction never throws). + await recordCreditDeduction({ + accountId, + creditsToDeduct: EMAIL_CREDIT_COST, + source: "api", + modelId: EMAIL_USAGE_MODEL_ID, + }); - if (result.success === false) { return NextResponse.json( - { status: "error", error: result.error }, - { status: 502, headers: getCorsHeaders() }, + { success: true, message: result.message, id: result.id }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + // Anything unexpected (e.g. a Stripe error inside the credit gate) returns a + // controlled 500 with CORS headers instead of an uncaught error. + console.error("[sendEmailHandler]", error); + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, ); } - - return NextResponse.json( - { success: true, message: result.message, id: result.id }, - { status: 200, headers: getCorsHeaders() }, - ); }