Skip to content

Email when feedback quota is almost full or exhausted #7

Description

@ezzcodeezzlife

Summary

Email the account owner when their rolling feedback quota is nearly full and when it is exhausted. Today they only find out via a 429 on the widget (POST /f) or by opening Account.

This is a retention/billing email: Free is 10 / 30 days, Pro is 100 / 30 days (lib/billing.ts). Hitting the wall mid-client-week without warning looks like an outage.

Current behavior

  • Quota is a rolling 30-day window counted from UserFeedbackLimitEvent (prisma/schema.prisma), not a calendar month.
  • Enforced in the POST /f transaction (app/f/route.ts): count events createdAt >= now - 30d; if >= limit, throw FeedbackQuotaExceededError → JSON 429 “Feedback quota exceeded. You can submit at most {n} feedbacks per 30 days.”
  • Account UI (app/account/page.tsx) shows used / limit, a bar, remaining, and an approximate reset date from the oldest event still in the window.
  • Email stack: Resend. Templates exist for welcome (lib/email/send-welcome-email.ts) and PR created (lib/email/send-pr-created-email.ts). Both support RESEND_EMAIL_MODE=test.
  • No quota emails. No table to record that we already warned someone this window.

Widget visitors see the 429 message, not the owner. The owner may have PR emails off and never notice until a client complains.

Proposed design

Triggers (after a successful submit, same request/after() as agent start)

Compute used and limit after inserting the new UserFeedbackLimitEvent.

Condition Email Max frequency
used / limit >= 0.8 and used < limit Almost full Once per rolling window (see below)
used === limit Full Once per window

Do not email at 80% on every submit between 8/10 and 9/10. Latch it.

80% on Free is 8/10. 80% on Pro is 80/100. Use >= 80% not a hardcoded “2 remaining” so Pro does not email at 98 leftover.

Also send Full even if they somehow skipped 80% (e.g. we shipped this feature when they were already at 9/10).

Deduping (rolling windows are annoying)

A calendar-month flag is wrong because quota is rolling.

Store on User (simple):

quotaWarn80SentForEventId  String?  // UserFeedbackLimitEvent id that crossed 80%
quotaWarn100SentForEventId String?

Or a small UserQuotaEmail table (userId, kind, windowAnchor). v1 can be two nullable ids:

  • Send 80% only if quotaWarn80SentForEventId is null or that event has fallen out of the current window (older than 30 days). When the oldest events age out, usage drops and they can be warned again later — correct.
  • When we send 80%, set the latch to the event id that crossed the threshold.
  • Same for 100%.

If they upgrade to Pro, remaining jumps up — do not send “full” after upgrade. If they were latched at Free 10/10 and then upgrade, clear both latches (or compare against new limit before sending).

Copy (assumptions)

Tone: operator, same monospace/orange as existing mail ([ quota ] kicker).

80% (Free example):
Subject: You’ve used 8 of 10 feedbacks
Body: remaining count, rolling-window explanation, date the oldest submission falls out (resetAtIso logic already on account page), link to /account to upgrade, link to dashboard.

100%:
Subject: Feedback quota reached
Body: new widget submits are blocked until {reset date} or they upgrade. Clients will see the widget error — say that explicitly so they are not blindsided. CTA: Upgrade to Pro ($20/mo) / Manage billing.

Do not email the client/visitor.

Respect… we do not have a “quota emails” toggle yet. v1: always on if they have an email (same as welcome). Follow-up: add a toggle next to “Email me when feedback creates a PR”. Until then, mention in the footer “you got this because you are on the Free/Pro plan at feedback2code.dev”.

PR-created emails can be disabled per repo; quota is account-level — do not reuse receivePrCreatedEmail.

Where to hook

after() in app/f/route.ts already starts the E2B agent. Add a non-blocking sendQuotaWarningIfNeeded(userId) there (or inside the transaction after count). Must not delay the 201. Must not fail the submission if Resend is down (try/catch like welcome).

Do not only check on Account page view — they might never open it.

Upgrade path

CTA → existing Stripe checkout (app/api/billing/checkout/route.ts) / Account billing actions (components/account/billing-actions.tsx shows “Upgrade to Pro ($20/mo)”).

If we later have EUR prices, this template should use the same formatter as the account page (see currency issue). v1 can keep $20.

Assumptions

  • One recipient: User.email (GitHub OAuth). No billing-CC, no repo collaborators.
  • We send from the same RESEND_FROM_EMAIL as other mail.
  • Test mode uses RESEND_TEST_TO like the other templates; add scripts/test-quota-email.mjs mirroring scripts/test-welcome-email.mjs.
  • Hitting 429 without a successful insert (already at cap) should still send Full if not yet latched — today the transaction throws before create, so a user who was already at cap before we shipped this never gets mail. On 429 path, also attempt Full email (they are at cap). That covers “feature launched, user already full”.
  • Discord notify for quota is optional and out of scope (noisy).

Acceptance criteria

  • Crossing 80% sends exactly one Almost Full email until that latch expires out of the 30-day window.
  • Hitting the limit sends exactly one Full email per window; 429 path also tries Full if never sent.
  • Upgrade to Pro clears latches / does not send Free “full” after the limit has increased.
  • Emails include used/limit, remaining, and a correct-enough reset date (same formula as Account).
  • Failure to send email does not fail widget submit or agent start.
  • Test script + RESEND_EMAIL_MODE=test works.
  • No email on every submit in the 80–99% band.

Out of scope

  • In-dashboard toast/banner (nice; separate, not a substitute — owners are not always logged in).
  • SMS / Slack.
  • Predicting “you will hit the cap on Friday” from rate.

Implementation notes

  • lib/email/send-quota-email.ts (html + text, escape like PR mail).
  • Hook from app/f/route.ts (success + 429).
  • Account page can show “we emailed you on …” later; not required.
  • Mirror existing logging: console.log("[email] …") / console.warn if no API key.

See also

  • EUR/USD (amount in the upgrade CTA)
  • Onboarding (persona does not change v1 copy)

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:billingStripe, plans, quota, currencyarea:emailTransactional email (Resend)enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions