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 3b5fa6e8..a0086b4d 100644 --- a/lib/emails/__tests__/sendEmailHandler.test.ts +++ b/lib/emails/__tests__/sendEmailHandler.test.ts @@ -4,6 +4,9 @@ import { sendEmailHandler } from "../sendEmailHandler"; 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), @@ -13,6 +16,22 @@ 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/emails/notifyEmailSent", () => ({ + notifyEmailSent: (...args: unknown[]) => mockNotifyEmailSent(...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 +60,71 @@ describe("sendEmailHandler", () => { message: "Email sent successfully.", id: "resend-id-1", }); + 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 () => { + 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/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 006bd002..35ad39f4 100644 --- a/lib/emails/sendEmailHandler.ts +++ b/lib/emails/sendEmailHandler.ts @@ -2,6 +2,23 @@ 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"; + +/** + * 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 +30,72 @@ 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 } = validated; + const { to, cc = [], subject, text, html = "", headers = {}, chat_id, accountId } = validated; - const result = await processAndSendEmail({ - to, - cc, - subject, - text, - html, - headers, - room_id: chat_id, - }); + 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() }, + ); + } + + // Charge on success (best-effort: recordCreditDeduction never throws). + await recordCreditDeduction({ + accountId, + creditsToDeduct: EMAIL_CREDIT_COST, + source: "api", + modelId: EMAIL_USAGE_MODEL_ID, + }); + + // Admin Telegram ping for quality/frequency review (best-effort, non-blocking). + await notifyEmailSent({ accountId, to, cc, subject, resendId: result.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() }, - ); }