Skip to content

feat(emails): charge 1 credit per send (POST /api/emails)#717

Open
sweetmantech wants to merge 3 commits into
testfrom
feat/emails-charge-credits
Open

feat(emails): charge 1 credit per send (POST /api/emails)#717
sweetmantech wants to merge 3 commits into
testfrom
feat/emails-charge-credits

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Why

POST /api/emails charged 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:

  1. ensureCreditsOrShortCircuit gates 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.
  2. Send.
  3. On success, recordCreditDeduction({ creditsToDeduct: 1, source: "api" }) — the atomic wallet+meter wrapper (one transaction: credits_usage debit + usage_events insert). A failed send (502) is not charged.

EMAIL_CREDIT_COST is a named, documented const. Only /api/emails charges — the shared processAndSendEmail (MCP send_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 with model_id = "POST /api/emails" and returns a CORS-safe 500 on unexpected credit gate failures.

  • New Features

    • Gate with ensureCreditsOrShortCircuit; 402 and no send if insufficient credits, with auto-recharge via CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL.
    • On success, deduct EMAIL_CREDIT_COST = 1 via recordCreditDeduction({ source: "api", modelId: "POST /api/emails" }); no charge on 502 failures.
    • Only POST /api/emails is billed; the shared processAndSendEmail path remains unchanged.
  • Bug Fixes

    • Wrap sendEmailHandler in 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.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Email sending now uses credit-based billing, checking available credits before sending.
    • Email sends now include billing metadata to support usage tracking.
  • Bug Fixes

    • Improved error handling so unexpected email send failures return a controlled response instead of an unhandled error.
    • Prevents email attempts when credits are insufficient, helping avoid failed sends and inconsistent billing.

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>
@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jun 26, 2026 1:04am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This 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.

Changes

Email billing flow

Layer / File(s) Summary
Billing constants and imports
lib/emails/sendEmailHandler.ts
Exports EMAIL_CREDIT_COST and EMAIL_USAGE_MODEL_ID and imports the billing helpers used by the email handler.
Credits-gated send flow
lib/emails/sendEmailHandler.ts
Validates accountId, short-circuits when credits are insufficient, sends the email, records the credit deduction on success, and returns a controlled 500 response for unexpected errors.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • recoupable/api#570: Uses the same credits-metering path via recordCreditDeduction and related usage tracking helpers.
  • recoupable/api#612: Also routes a request flow through shared credit debit and usage recording logic.

Poem

A mailer tapped the credit gate,
Then sailed through send-or-seal-the-fate.
If stars and credits both align,
The ledger writes a tidy line.
✉️✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning sendEmailHandler is 58 lines and mixes validation, billing, send, deduction, and error handling; it also logs raw caught errors, violating the clean-code limits. Split the route into smaller helpers (in appropriately named files if adding functions) and replace raw console.error(error) with sanitized metadata-only logging.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/emails-charge-credits

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread lib/emails/sendEmailHandler.ts Outdated
…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>
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Fixed the cubic finding (f514770d): the credit gate (ensureCreditsOrShortCircuitautoRechargeOrFail) makes Stripe calls (resolveStripeCustomerForAccount, chargeCustomerOffSession, createCreditsStripeSession) and can throw — confirmed valid. Wrapped the whole handler body in try/catch returning a controlled { status: "error" } 500 with CORS headers on any unexpected throw, instead of an uncaught error. Added a test (gate throws → 500 + CORS, no send). 7 handler tests green; tsc/lint clean.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");

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: 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>

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview test — 1 credit charged per send, verified end-to-end ✅

Tested on the branch preview https://api-git-feat-emails-charge-credits-recoup.vercel.app (commit f514770d, account fb678396).

Step Result
credits_usage.remaining_credits before 989
POST /api/emails (1 send) 200, delivered (Resend a250bc29-…)
credits_usage.remaining_credits after 988 — exactly −1 (EMAIL_CREDIT_COST)
usage_events row ✅ new row source: api, credits_deducted_cents: 1, ts 00:54:38 (matches the send)

So both halves of the atomic write landed: the wallet (credits_usage) debited by 1 and the meter (usage_events) recorded the charge. Deduction happens only on a successful send.

Not forced on the deployment (unit-tested instead):

  • 402 insufficient — this account has a card on file, so the gate auto-recharges ($5 → 500 credits) rather than 402. Covered by the handler unit test (ensureCreditsOrShortCircuit → 402, no send, no charge).
  • 500 gate-throws (the cubic fix) — can't force a Stripe error on demand; covered by the new unit test (gate throws → controlled 500 with CORS, no send).

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>
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Update — usage_events.model_id added + verified (fd178c10)

Per the request to track endpoint usage by query, each send now stamps model_id = "POST /api/emails" on the usage_events row (recordCreditDeduction({ … modelId })).

Verified on the preview — sent one email, latest usage_events row:

source = api | model_id = "POST /api/emails" | credits_deducted_cents = 1

So usage is now queryable:

select count(*) from usage_events where model_id = 'POST /api/emails';

(The earlier test send — before this commit — has model_id = null, as expected.)

Recap of what's on this PR now

  1. Charge 1 credit/send (gate→send→deduct; recordCreditDeduction source: api). Verified: 989 → 988, usage_events row written.
  2. Cubic fix: handler wrapped in try/catch → controlled 500 + CORS if the credit gate throws (Stripe error).
  3. model_id stamping (this update).

7 handler tests green; tsc 0 new errors; lint clean. The stacked Telegram PR (api#718) was merged-forward to include all three.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
lib/emails/sendEmailHandler.ts (1)

40-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Split the handler to meet the function-size/SRP rules.

sendEmailHandler is 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/**/*.ts functions 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

📥 Commits

Reviewing files that changed from the base of the PR and between cae9f35 and fd178c1.

⛔ Files ignored due to path filters (1)
  • lib/emails/__tests__/sendEmailHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (1)
  • lib/emails/sendEmailHandler.ts

Comment on lines +76 to +82
// Charge on success (best-effort: recordCreditDeduction never throws).
await recordCreditDeduction({
accountId,
creditsToDeduct: EMAIL_CREDIT_COST,
source: "api",
modelId: EMAIL_USAGE_MODEL_ID,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 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.

Comment on lines +88 to +94
} 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() },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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

‼️ 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.

Suggested change
} 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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant