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
48 changes: 48 additions & 0 deletions lib/emails/__tests__/notifyEmailSent.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
84 changes: 84 additions & 0 deletions lib/emails/__tests__/sendEmailHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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": "*" })),
}));
Expand Down Expand Up @@ -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 () => {
Expand Down
48 changes: 48 additions & 0 deletions lib/emails/notifyEmailSent.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
try {
await sendMessage(formatEmailSentMessage(n), { parse_mode: "Markdown" });

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: Raw dynamic fields are interpolated into Telegram Markdown without escaping. Special characters in addresses/subject can make sendMessage fail, so successful emails may not be reported.

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

<comment>Raw dynamic fields are interpolated into Telegram Markdown without escaping. Special characters in addresses/subject can make sendMessage fail, so successful emails may not be reported.</comment>

<file context>
@@ -0,0 +1,48 @@
+ */
+export async function notifyEmailSent(n: EmailSentNotification): Promise<void> {
+  try {
+    await sendMessage(formatEmailSentMessage(n), { parse_mode: "Markdown" });
+  } catch (err) {
+    console.error("Error in notifyEmailSent:", err);
</file context>

} catch (err) {
console.error("Error in notifyEmailSent:", err);
}
}
97 changes: 75 additions & 22 deletions lib/emails/sendEmailHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<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 } = 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() },
);
}
Loading