feat(emails): charge 1 credit per send (POST /api/emails)#717
feat(emails): charge 1 credit per send (POST /api/emails)#717sweetmantech wants to merge 3 commits into
Conversation
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) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR adds credits-based gating to email sends. It exports billing constants, checks account credits before sending, records a credit deduction after successful sends, and returns controlled JSON errors for unexpected failures. ChangesEmail billing flow
Sequence Diagram(s)sequenceDiagram
participant Request as NextRequest
participant Handler as sendEmailHandler
participant Billing as ensureCreditsOrShortCircuit
participant Ledger as recordCreditDeduction
participant Response as NextResponse
Request->>Handler: POST /api/emails with accountId
Handler->>Billing: ensure EMAIL_CREDIT_COST for the account
alt insufficient credits
Billing-->>Handler: short-circuit response
Handler-->>Response: return short-circuit JSON
else credits available
Billing-->>Handler: continue
Handler->>Handler: attempt email send
Handler->>Handler: inspect result.success
alt send succeeded
Handler->>Ledger: record EMAIL_CREDIT_COST and EMAIL_USAGE_MODEL_ID
Handler-->>Response: return success JSON
else send failed
Handler-->>Response: return failure JSON
end
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
1 issue found across 2 files
Confidence score: 3/5
- In
lib/emails/sendEmailHandler.ts, a credit-gate failure can throw outside the handler’s normal error path, which may return an uncontrolled error shape or miss CORS headers and break client-side handling of send failures. Wrap the gate call in the handler’s try/catch flow and return a controlled 500 JSON response with CORS headers before merging.
Architecture diagram
sequenceDiagram
participant Client as External Client
participant Handler as sendEmailHandler (POST /api/emails)
participant Validator as validateSendEmailBody
participant Credits as ensureCreditsOrShortCircuit
participant Wallet as recordCreditDeduction
participant Email as processAndSendEmail
participant Resend as Resend API
Note over Client,Resend: Email send flow with credit gating and deduction
Client->>Handler: POST /api/emails (to, subject, text, etc.)
Handler->>Validator: validate request body + auth
Validator-->>Handler: validated data (includes accountId)
Handler->>Credits: ensureCreditsOrShortCircuit(accountId, creditsToDeduct:1, successUrl)
alt Credits insufficient (402 path)
Credits->>Credits: Attempt auto-recharge via card on file
alt Auto-recharge succeeds
Credits-->>Handler: null (proceed)
else Auto-recharge fails or no card
Credits-->>Handler: NextResponse 402 (Insufficient credits)
Handler-->>Client: 402 error response
end
else Credits sufficient
Credits-->>Handler: null (proceed)
end
Handler->>Email: processAndSendEmail(to, cc, subject, text, html, headers, chat_id)
Email->>Resend: send email via Resend API
Resend-->>Email: Send result (success/failure)
alt Send successful
Email-->>Handler: { success: true, message, id }
Handler->>Wallet: recordCreditDeduction(accountId, creditsToDeduct:1, source:"api")
Wallet->>Wallet: Atomic transaction: debit credits_usage + insert usage_events
Wallet-->>Handler: { success: true }
Handler-->>Client: 200 (success=true, message, id)
else Send failed
Email-->>Handler: { success: false, error }
Handler-->>Client: 502 error response (no credit deduction)
end
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…iew)
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) <noreply@anthropic.com>
|
Fixed the cubic finding ( |
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/emails/__tests__/sendEmailHandler.test.ts">
<violation number="1" location="lib/emails/__tests__/sendEmailHandler.test.ts:96">
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.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| expect(response.status).toBe(500); | ||
| expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); | ||
| const json = await response.json(); | ||
| expect(json.status).toBe("error"); |
There was a problem hiding this comment.
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>
Preview test — 1 credit charged per send, verified end-to-end ✅Tested on the branch preview
So both halves of the atomic write landed: the wallet ( Not forced on the deployment (unit-tested instead):
7 handler tests green; tsc adds 0 new errors; lint clean. |
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) <noreply@anthropic.com>
Update —
|
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
lib/emails/sendEmailHandler.ts (1)
40-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSplit the handler to meet the function-size/SRP rules.
sendEmailHandleris now doing validation, credit gating, email dispatch, billing, and error shaping in one 58-line function. Extract focused domain functions into their own matching files so the handler remains orchestration-only.As per coding guidelines, “Flag functions longer than 20 lines.” As per path instructions,
lib/**/*.tsfunctions should “Keep functions under 50 lines” and follow “Single responsibility per function.”🤖 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 40 - 97, `sendEmailHandler` is doing validation, credit gating, email dispatch, billing, and error handling all in one place, so split it into smaller SRP-focused helpers. Extract the validation, credit check, send/charge flow, and error shaping into dedicated functions near `sendEmailHandler`, then keep `sendEmailHandler` as a thin orchestrator that calls those helpers and returns the final `NextResponse`.Sources: Coding guidelines, Path instructions
🤖 Prompt for all review comments with 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.
Inline comments:
In `@lib/emails/sendEmailHandler.ts`:
- Around line 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.
- Around line 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.
---
Nitpick comments:
In `@lib/emails/sendEmailHandler.ts`:
- Around line 40-97: `sendEmailHandler` is doing validation, credit gating,
email dispatch, billing, and error handling all in one place, so split it into
smaller SRP-focused helpers. Extract the validation, credit check, send/charge
flow, and error shaping into dedicated functions near `sendEmailHandler`, then
keep `sendEmailHandler` as a thin orchestrator that calls those helpers and
returns the final `NextResponse`.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 57765b10-ed7d-47c3-b50e-d706b86292c8
⛔ Files ignored due to path filters (1)
lib/emails/__tests__/sendEmailHandler.test.tsis excluded by!**/*.test.*,!**/__tests__/**and included bylib/**
📒 Files selected for processing (1)
lib/emails/sendEmailHandler.ts
| // Charge on success (best-effort: recordCreditDeduction never throws). | ||
| await recordCreditDeduction({ | ||
| accountId, | ||
| creditsToDeduct: EMAIL_CREDIT_COST, | ||
| source: "api", | ||
| modelId: EMAIL_USAGE_MODEL_ID, | ||
| }); |
There was a problem hiding this comment.
🗄️ 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.
| } 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() }, |
There was a problem hiding this comment.
🔒 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.
| } 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.
There was a problem hiding this comment.
0 issues found across 2 files (changes from recent commits).
Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.
Re-trigger cubic
Why
POST /api/emailscharged nothing — sends (incl. agent/scheduled) were free and unmetered (chat#1815 billing item).Pricing → 1 credit
1 credit = $0.01. Resend's per-email cost is ≤ $0.0004 (cheapest paid tier: Pro $20 / 50,000 emails; every tier is well under $0.01). Per the rule "round up to $0.01 if below, else charge at cost without markup" → 1 credit per email.
How
Mirrors the deep-research handler's gate→work→deduct pattern:
ensureCreditsOrShortCircuitgates the send — returns 402 (no send) if the account can't cover it, auto-recharging via a card on file when possible. It does not deduct.recordCreditDeduction({ creditsToDeduct: 1, source: "api" })— the atomic wallet+meter wrapper (one transaction:credits_usagedebit +usage_eventsinsert). A failed send (502) is not charged.EMAIL_CREDIT_COSTis a named, documented const. Only/api/emailscharges — the sharedprocessAndSendEmail(MCPsend_email) path is unchanged.Tests
6 handler tests green: gate→charge on success (cost 1,
source: api), 402 + no send when insufficient, no charge on send failure. tsc/lint clean.Tracked on chat#1815.
🤖 Generated with Claude Code
Summary by cubic
Charge 1 credit per email for
POST /api/emails, with a credit gate upfront and deduction only after a successful send. Also tags usage withmodel_id = "POST /api/emails"and returns a CORS-safe 500 on unexpected credit gate failures.New Features
ensureCreditsOrShortCircuit; 402 and no send if insufficient credits, with auto-recharge viaCREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL.EMAIL_CREDIT_COST = 1viarecordCreditDeduction({ source: "api", modelId: "POST /api/emails" }); no charge on 502 failures.POST /api/emailsis billed; the sharedprocessAndSendEmailpath remains unchanged.Bug Fixes
sendEmailHandlerin try/catch to return a{ status: "error" }500 with CORS when the credit gate throws.Written for commit fd178c1. Summary will update on new commits.
Summary by CodeRabbit
New Features
Bug Fixes