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
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)
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
UserFeedbackLimitEvent(prisma/schema.prisma), not a calendar month.POST /ftransaction (app/f/route.ts): count eventscreatedAt >= now - 30d; if>= limit, throwFeedbackQuotaExceededError→ JSON429“Feedback quota exceeded. You can submit at most {n} feedbacks per 30 days.”app/account/page.tsx) showsused / limit, a bar, remaining, and an approximate reset date from the oldest event still in the window.lib/email/send-welcome-email.ts) and PR created (lib/email/send-pr-created-email.ts). Both supportRESEND_EMAIL_MODE=test.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
usedandlimitafter inserting the newUserFeedbackLimitEvent.used / limit >= 0.8andused < limitused === limitDo 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):Or a small
UserQuotaEmailtable(userId, kind, windowAnchor). v1 can be two nullable ids:quotaWarn80SentForEventIdis 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.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 feedbacksBody: remaining count, rolling-window explanation, date the oldest submission falls out (
resetAtIsologic already on account page), link to/accountto upgrade, link to dashboard.100%:
Subject:
Feedback quota reachedBody: 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()inapp/f/route.tsalready starts the E2B agent. Add a non-blockingsendQuotaWarningIfNeeded(userId)there (or inside the transaction after count). Must not delay the 201. Must not fail the submission if Resend is down (try/catchlike 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.tsxshows “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
User.email(GitHub OAuth). No billing-CC, no repo collaborators.RESEND_FROM_EMAILas other mail.RESEND_TEST_TOlike the other templates; addscripts/test-quota-email.mjsmirroringscripts/test-welcome-email.mjs.Acceptance criteria
RESEND_EMAIL_MODE=testworks.Out of scope
Implementation notes
lib/email/send-quota-email.ts(html + text, escape like PR mail).app/f/route.ts(success + 429).console.log("[email] …")/console.warnif no API key.See also