Skip to content
Open
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
59 changes: 59 additions & 0 deletions lib/emails/__tests__/sendEmailHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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": "*" })),
}));
Expand Down Expand Up @@ -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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Test doesn't assert the error message is the safe hardcoded string — could mask a leak of the raw exception text in the response.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/__tests__/sendEmailHandler.test.ts, line 96:

<comment>Test doesn't assert the error message is the safe hardcoded string — could mask a leak of the raw exception text in the response.</comment>

<file context>
@@ -87,6 +87,16 @@ describe("sendEmailHandler", () => {
+    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();
+  });
</file context>

expect(mockProcessAndSendEmail).not.toHaveBeenCalled();
});

it("sends to the validated recipients and maps chat_id to the footer link", async () => {
Expand Down
93 changes: 71 additions & 22 deletions lib/emails/sendEmailHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<NextResponse> {
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,
});
Comment on lines +76 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the post-send billing write durable, not best-effort.

Line 76 says recordCreditDeduction never throws, so a failed wallet/usage write can still return a successful email response with no charge recorded. That violates the PR’s billing contract for successful sends; make the deduction failure observable/durable, e.g. explicit failure result + alert/retry/outbox before returning success.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/emails/sendEmailHandler.ts` around lines 76 - 82, The post-send billing
step in sendEmailHandler should not be treated as best-effort, because
recordCreditDeduction can fail silently and still let the email request return
success. Update the sendEmailHandler flow to make the deduction outcome durable
and observable, using recordCreditDeduction as a required step after a
successful send and handling failures explicitly via a failure result,
retry/outbox, or alert path before completing the response.


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() },
Comment on lines +88 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Avoid logging raw caught errors from the email request path.

Raw external/request errors can include email addresses, headers, account identifiers, or provider payloads. Log sanitized fields instead of the full object.

Safer logging shape
-    console.error("[sendEmailHandler]", error);
+    console.error("[sendEmailHandler]", {
+      name: error instanceof Error ? error.name : "NonError",
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} 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() },
} 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]", {
name: error instanceof Error ? error.name : "NonError",
});
return NextResponse.json(
{ status: "error", error: "Internal server error" },
{ status: 500, headers: getCorsHeaders() },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/emails/sendEmailHandler.ts` around lines 88 - 94, The catch block in
sendEmailHandler is logging the raw error object, which can expose sensitive
request/provider data. Update the error handling in sendEmailHandler to log only
sanitized, structured fields instead of the full caught error, and keep the
controlled 500 response unchanged. Use the sendEmailHandler catch path and the
existing console.error call as the place to replace with safe, redacted logging.

);
}

return NextResponse.json(
{ success: true, message: result.message, id: result.id },
{ status: 200, headers: getCorsHeaders() },
);
}
Loading