-
Notifications
You must be signed in to change notification settings - Fork 9
feat(emails): charge 1 credit per send (POST /api/emails) #717
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: test
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||||
| { success: true, message: result.message, id: result.id }, | ||||||||||||||||||||||||||||||||||
| { status: 200, headers: getCorsHeaders() }, | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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