From 5c5cb7c426498650b64fff410057f77bec2fc664 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 25 Jun 2026 19:34:28 -0500 Subject: [PATCH 1/4] feat(emails): charge 1 credit per send via POST /api/emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sends were free/unmetered. Charge EMAIL_CREDIT_COST = 1 credit ($0.01): Resend's per-email cost is <= $0.0004 (cheapest paid tier Pro $20/50k), which rounds up to the $0.01 minimum, so 1 credit, no markup. Pattern (matches the deep-research handler): ensureCreditsOrShortCircuit gates first — 402 if the account can't cover it (auto-recharges via a card on file) and does NOT deduct; then deduct only on a successful send via recordCreditDeduction (atomic credits_usage debit + usage_events insert, source "api"). A failed send (502) is not charged. Tests: gate→charge on success, 402 + no send when insufficient, no charge on send failure. 6 handler tests green; tsc/lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/emails/__tests__/sendEmailHandler.test.ts | 44 +++++++++++++++++++ lib/emails/sendEmailHandler.ts | 34 +++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/lib/emails/__tests__/sendEmailHandler.test.ts b/lib/emails/__tests__/sendEmailHandler.test.ts index 3b5fa6e8..11db158e 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,36 @@ 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" }), + ); + }); + + 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("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..e1da4e1d 100644 --- a/lib/emails/sendEmailHandler.ts +++ b/lib/emails/sendEmailHandler.ts @@ -2,6 +2,16 @@ 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; /** * Handler for POST /api/emails. @@ -13,6 +23,11 @@ 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. */ @@ -22,7 +37,16 @@ export async function sendEmailHandler(request: NextRequest): Promise Date: Thu, 25 Jun 2026 19:37:57 -0500 Subject: [PATCH 2/4] feat(emails): post an Admin Telegram notification on every email sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So the team can review the quality + frequency of outgoing email (esp. agent/scheduled sends). New notifyEmailSent() formats account + to/cc + subject + Resend id + timestamp and posts to the same TELEGRAM_CHAT_ID channel as other alerts via the existing sendMessage(). Best-effort (try/catch, never throws), mirroring sendErrorNotification — a Telegram failure never blocks the send. Wired into sendEmailHandler after a successful send (not charged/notified on failure). Tests: notifyEmailSent formats + swallows errors; handler notifies on success, not on failure. 11 emails tests green; tsc/lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/emails/__tests__/notifyEmailSent.test.ts | 48 +++++++++++++++++++ lib/emails/__tests__/sendEmailHandler.test.ts | 25 ++++++++++ lib/emails/notifyEmailSent.ts | 48 +++++++++++++++++++ lib/emails/sendEmailHandler.ts | 4 ++ 4 files changed, 125 insertions(+) create mode 100644 lib/emails/__tests__/notifyEmailSent.test.ts create mode 100644 lib/emails/notifyEmailSent.ts diff --git a/lib/emails/__tests__/notifyEmailSent.test.ts b/lib/emails/__tests__/notifyEmailSent.test.ts new file mode 100644 index 00000000..223e92ff --- /dev/null +++ b/lib/emails/__tests__/notifyEmailSent.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { notifyEmailSent } from "../notifyEmailSent"; + +const mockSendMessage = vi.fn(); +vi.mock("@/lib/telegram/sendMessage", () => ({ + sendMessage: (...args: unknown[]) => mockSendMessage(...args), +})); + +describe("notifyEmailSent", () => { + beforeEach(() => vi.clearAllMocks()); + + it("posts a Markdown message with the email details", async () => { + await notifyEmailSent({ + accountId: "acct-1", + to: ["dest@example.com"], + cc: ["cc@example.com"], + subject: "Weekly report", + resendId: "resend-123", + }); + + expect(mockSendMessage).toHaveBeenCalledTimes(1); + const [message, options] = mockSendMessage.mock.calls[0]; + expect(options).toEqual({ parse_mode: "Markdown" }); + expect(message).toContain("acct-1"); + expect(message).toContain("dest@example.com"); + expect(message).toContain("cc@example.com"); + expect(message).toContain("Weekly report"); + expect(message).toContain("resend-123"); + }); + + it("omits the CC line when there is no cc", async () => { + await notifyEmailSent({ + accountId: "acct-1", + to: ["dest@example.com"], + subject: "Hi", + resendId: "r-1", + }); + const [message] = mockSendMessage.mock.calls[0]; + expect(message).not.toContain("*CC:*"); + }); + + it("never throws when Telegram fails (best-effort)", async () => { + mockSendMessage.mockRejectedValue(new Error("telegram down")); + await expect( + notifyEmailSent({ accountId: "a", to: ["x@y.com"], subject: "s", resendId: "r" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/lib/emails/__tests__/sendEmailHandler.test.ts b/lib/emails/__tests__/sendEmailHandler.test.ts index 11db158e..5946eed5 100644 --- a/lib/emails/__tests__/sendEmailHandler.test.ts +++ b/lib/emails/__tests__/sendEmailHandler.test.ts @@ -6,6 +6,7 @@ const mockValidateSendEmailBody = vi.fn(); const mockProcessAndSendEmail = vi.fn(); const mockEnsureCredits = vi.fn(); const mockRecordCreditDeduction = vi.fn(); +const mockNotifyEmailSent = vi.fn(); vi.mock("@/lib/emails/validateSendEmailBody", () => ({ validateSendEmailBody: (...args: unknown[]) => mockValidateSendEmailBody(...args), @@ -23,6 +24,10 @@ vi.mock("@/lib/credits/recordCreditDeduction", () => ({ recordCreditDeduction: (...args: unknown[]) => mockRecordCreditDeduction(...args), })); +vi.mock("@/lib/emails/notifyEmailSent", () => ({ + notifyEmailSent: (...args: unknown[]) => mockNotifyEmailSent(...args), +})); + vi.mock("@/lib/credits/const", () => ({ CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL: "https://chat.recoupable.com/credits", })); @@ -57,6 +62,26 @@ describe("sendEmailHandler", () => { }); mockEnsureCredits.mockResolvedValue(null); // credits available → proceed mockRecordCreditDeduction.mockResolvedValue({ success: true }); + mockNotifyEmailSent.mockResolvedValue(undefined); + }); + + it("posts an Admin Telegram notification on a successful send", async () => { + const response = await sendEmailHandler(createRequest()); + expect(response.status).toBe(200); + expect(mockNotifyEmailSent).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "account-123", + to: ["dest@example.com"], + subject: "Weekly report", + resendId: "resend-id-1", + }), + ); + }); + + it("does not notify when the send fails", async () => { + mockProcessAndSendEmail.mockResolvedValue({ success: false, error: "resend boom" }); + await sendEmailHandler(createRequest()); + expect(mockNotifyEmailSent).not.toHaveBeenCalled(); }); it("gates on credits then charges 1 credit on a successful send", async () => { diff --git a/lib/emails/notifyEmailSent.ts b/lib/emails/notifyEmailSent.ts new file mode 100644 index 00000000..611ceb55 --- /dev/null +++ b/lib/emails/notifyEmailSent.ts @@ -0,0 +1,48 @@ +import { sendMessage } from "@/lib/telegram/sendMessage"; + +export interface EmailSentNotification { + accountId: string; + to: string[]; + cc?: string[]; + subject: string; + resendId: string; +} + +/** + * Formats a sent-email notification into a Telegram message. + * + * @param n - The sent-email details. + * @returns A Markdown-formatted message string. + */ +function formatEmailSentMessage(n: EmailSentNotification): string { + const lines = [ + "*Email sent* (`POST /api/emails`)", + "", + `*Account:* ${n.accountId}`, + `*To:* ${n.to.join(", ")}`, + ]; + if (n.cc && n.cc.length > 0) { + lines.push(`*CC:* ${n.cc.join(", ")}`); + } + lines.push(`*Subject:* ${n.subject}`); + lines.push(`*Resend ID:* ${n.resendId}`); + lines.push(""); + lines.push(`*Time:* ${new Date().toISOString()}`); + return lines.join("\n"); +} + +/** + * Posts an Admin Telegram notification for each email sent (the same + * `TELEGRAM_CHAT_ID` channel as other alerts), so the team can review the + * quality and frequency of outgoing email. Best-effort — never throws or + * blocks the send, mirroring `sendErrorNotification`. + * + * @param n - The sent-email details. + */ +export async function notifyEmailSent(n: EmailSentNotification): Promise { + try { + await sendMessage(formatEmailSentMessage(n), { parse_mode: "Markdown" }); + } catch (err) { + console.error("Error in notifyEmailSent:", err); + } +} diff --git a/lib/emails/sendEmailHandler.ts b/lib/emails/sendEmailHandler.ts index e1da4e1d..af19071a 100644 --- a/lib/emails/sendEmailHandler.ts +++ b/lib/emails/sendEmailHandler.ts @@ -2,6 +2,7 @@ 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 { notifyEmailSent } from "@/lib/emails/notifyEmailSent"; import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortCircuit"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; import { CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL } from "@/lib/credits/const"; @@ -73,6 +74,9 @@ export async function sendEmailHandler(request: NextRequest): Promise Date: Thu, 25 Jun 2026 19:50:43 -0500 Subject: [PATCH 3/4] =?UTF-8?q?fix(emails):=20wrap=20sendEmailHandler=20in?= =?UTF-8?q?=20try/catch=20=E2=86=92=20controlled=20500=20(review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per cubic review: the credit gate (ensureCreditsOrShortCircuit → autoRechargeOrFail) makes Stripe calls and can throw, but the handler had no try/catch, so a Stripe/DB hiccup produced an uncaught, uncontrolled 500 with no CORS headers. Wrap the whole handler body and return a controlled { status: "error" } 500 with CORS on any unexpected throw. Added a test: gate throws → 500 with CORS, no send. 7 handler tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/emails/__tests__/sendEmailHandler.test.ts | 10 +++ lib/emails/sendEmailHandler.ts | 86 +++++++++++-------- 2 files changed, 58 insertions(+), 38 deletions(-) diff --git a/lib/emails/__tests__/sendEmailHandler.test.ts b/lib/emails/__tests__/sendEmailHandler.test.ts index 11db158e..61072904 100644 --- a/lib/emails/__tests__/sendEmailHandler.test.ts +++ b/lib/emails/__tests__/sendEmailHandler.test.ts @@ -87,6 +87,16 @@ describe("sendEmailHandler", () => { 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 () => { const response = await sendEmailHandler(createRequest()); diff --git a/lib/emails/sendEmailHandler.ts b/lib/emails/sendEmailHandler.ts index e1da4e1d..d9755c50 100644 --- a/lib/emails/sendEmailHandler.ts +++ b/lib/emails/sendEmailHandler.ts @@ -32,49 +32,59 @@ export const EMAIL_CREDIT_COST = 1; * @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 { 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 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, + }); + + 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", + }); - 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() }, + { 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() }, ); } - - // Charge on success (best-effort: recordCreditDeduction never throws). - await recordCreditDeduction({ - accountId, - creditsToDeduct: EMAIL_CREDIT_COST, - source: "api", - }); - - return NextResponse.json( - { success: true, message: result.message, id: result.id }, - { status: 200, headers: getCorsHeaders() }, - ); } From fd178c101beca8b4bbabd1641e2426c5a283d34a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 25 Jun 2026 20:02:43 -0500 Subject: [PATCH 4/4] feat(emails): stamp usage_events.model_id = "POST /api/emails" So endpoint usage is queryable per the request: select count(*) from usage_events where model_id = 'POST /api/emails' recordCreditDeduction already accepts modelId; pass EMAIL_USAGE_MODEL_ID. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/emails/__tests__/sendEmailHandler.test.ts | 7 ++++++- lib/emails/sendEmailHandler.ts | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/emails/__tests__/sendEmailHandler.test.ts b/lib/emails/__tests__/sendEmailHandler.test.ts index 61072904..e488e45c 100644 --- a/lib/emails/__tests__/sendEmailHandler.test.ts +++ b/lib/emails/__tests__/sendEmailHandler.test.ts @@ -66,7 +66,12 @@ describe("sendEmailHandler", () => { expect.objectContaining({ accountId: "account-123", creditsToDeduct: 1 }), ); expect(mockRecordCreditDeduction).toHaveBeenCalledWith( - expect.objectContaining({ accountId: "account-123", creditsToDeduct: 1, source: "api" }), + expect.objectContaining({ + accountId: "account-123", + creditsToDeduct: 1, + source: "api", + modelId: "POST /api/emails", + }), ); }); diff --git a/lib/emails/sendEmailHandler.ts b/lib/emails/sendEmailHandler.ts index d9755c50..3ea92020 100644 --- a/lib/emails/sendEmailHandler.ts +++ b/lib/emails/sendEmailHandler.ts @@ -13,6 +13,12 @@ import { CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL } from "@/lib/credits/const"; */ 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. * @@ -72,6 +78,7 @@ export async function sendEmailHandler(request: NextRequest): Promise