Skip to content

feat(abuse): hosted abuse controls for open signups - #91

Merged
clbotdev merged 9 commits into
mainfrom
issue-75-abuse-controls
Oct 19, 2026
Merged

clbotdev merged 9 commits into
mainfrom
issue-75-abuse-controls

Conversation

@clbotdev

Copy link
Copy Markdown
Contributor

Part of #79. Closes #71, #72, #73, #74 and the code side of #75.

Hosted (cloud) abuse controls, off for self-host (and ABUSE_CONTROLS=off). Operators work a review queue (abuse_flags, migration 0017) with bun scripts/abuse.ts (queue, show, release, confirm, suspend-org, unsuspend-org, pacing-exempt) — the one place listing flagged signups, held emails, large imports, suspended orgs and paused teams. Alerts email OPERATOR_ALERT_EMAIL (falls back to BILLING_ALERT_EMAIL).

  • Signup guards (Signup guards for hosted: block disposable/free emails, cap signups per IP per day, optional real-person check #71): disposable domains rejected before the sign-in code is sent (and for Google sign-ups); free-provider policy allow | flag | block (default flag → queued for review); max 3 new accounts per client IP per UTC day (Redis; IP from Express req.ip, honouring ENABLE_TRUST_PROXY, stamped on auth requests so clients can't spoof it); optional Exa risk score stored on the org — high risk starts with sending paused. Email verification was already in place (email OTP).
  • Content scan (AI spam/phishing scan of templates and broadcasts with auto-suspend (hosted) #72): broadcasts/sequences at start, test sends, and edits to running sequences are scanned — heuristics (link text vs. destination, credential lures with brand impersonation, executables, raw-IP/punycode links, shorteners, spam phrases) plus a Claude classifier when ANTHROPIC_API_KEY is set (claude-opus-5-5, low effort, structured output; a refusal means "review"). Blocked → team all_paused (sticky, operator release), owners emailed, operators alerted; review → held until released. Verdicts stored per canonical content hash; approved content passes.
  • New-account pacing (New-account send throttling: spread sends over the day for the first 30-60 days (hosted) #73): the existing marketing ramp plus hourly pacing for orgs younger than 30 days (PACING_HOURS, default 12); over-pace sends are deferred to the next hour, never failed. The broadcast page and GET /sequences/:id (pacing) show the pace and ETA; operators can exempt a vetted org.
  • Large imports (Alert admins on large contact imports for review (hosted) #74): every import is recorded in the org audit log (with the file name); >5,000 in 24 h (>1,000 for new accounts) or a 3× jump alerts operators with account age, plan, list size and free/role/disposable share; a new account's campaigns wait for review.
  • AUP page rewritten: reserves the right to review/suspend and documents these controls. Env vars in .env.example.

Not in this PR: the hosted login page (Platform @codelitdev/oauth-server-kit) replaces the API's message with "Sign-in failed. Please try again.", so the specific reason (e.g. disposable email) isn't shown yet — needs a Platform change.

Testing

  • Unit: heuristics, content block/release flow, signup guards incl. IP cap (real Redis), free-provider flag, import alert + hold + CLI release, pacing budgets. Full API suite, conformance and drift green.
  • Browser E2E (cloud mode, fake billing): disposable signup refused (403 DISPOSABLE_EMAIL, no code step); Gmail signup allowed and flagged; 4th signup from one IP refused; the new account imported 1,100 contacts → flag + "contact import is being reviewed" on Send now → released via CLI; a PayPal-lookalike email was blocked, paused the team, then released; the broadcast to 30 contacts sent 17 in the first hour with "Sending gradually … up to 17 emails an hour (200 a day). 13 to go".

POST /sequences/:id/emails/:emailId/test (and the MCP tool
send_sequence_email_test) renders the email exactly like a real send,
through the delivery source the send would use, with a [Test] subject.
It defaults to the signed-in user, takes up to five addresses, skips
suppressed ones and is capped at 50 recipients per team per hour.

Tracking tokens carry a contact id that matches nobody and the
unsubscribe link uses a test token whose page explains it was a test.
The email records lastTestSentAt. The broadcast editor and the
full-screen email editor get a Send test dialog.

The per-recipient render pipeline moves to mail/campaign.ts so real and
test sends share it.

Refs #34, #79
Every send now carries a text/plain alternative generated from the final
HTML (links as "text (url)", bulleted lists, no images, styles or
preheader), or the caller's own text for transactional sends (new
optional `text`, stored on the row).

Marketing emails link a hosted web version from the managed footer, also
available as the {{ view_in_browser_url }} merge tag. GET /view/:token
serves it for the signed recipient with their merge data and no
tracking, as a sandboxed document. The editor default no longer starts
with a plain "View in browser" line that linked nowhere.

Refs #35, #79
sendMail had no replyTo, so a transactional send's stored Reply-To (and
the organization grant's) never reached the recipient. It is now sent,
and team ESPs get an optional reply-to (REST, MCP, the ESP dialog) used
for broadcasts, sequences, tests and transactional sends without their
own. Organization grants fall back to their ESP's reply-to.

Refs #47, #79
Each ESP gets an optional maxSendRate (messages per second); when unset,
Amazon SES ESPs default to 14/s and Resend to 2/s. sendMail reserves a
slot per ESP with a Redis GCRA script, so sends through one ESP are
spaced evenly across every worker and process. A send that would wait
more than 30s, or that the provider throttles (421/45x, SES
"Throttling"), is deferred: sequences reschedule the row without
counting a failed attempt and transactional jobs are moved to delayed
without using an attempt.

REST, MCP and the ESP dialog expose the rate. CI gets a Redis service
for the limiter test.

Refs #51, #79
POST /contacts/import (MCP import_contacts) upserts up to 1000 rows per
request by email. Each row's source status is interpreted with a shared
vocabulary (beehiiv Active/Paused/Inactive/Bounced/Pending, Mailchimp
cleaned, ...) and a status only gets stricter: nobody is re-subscribed,
suppressed addresses stay unsubscribed, bounced and complained rows are
suppressed, paused contacts are tagged. Tags and custom fields merge,
the earliest signup date wins, imports don't trigger sequences, and
subscribed contacts over the plan limit are skipped. The response counts
created/updated/unchanged/suppressed rows and lists skipped rows with
reasons.

GET /contacts/export (MCP export_contacts) returns CSV with status,
tags, created_at, suppression reason and custom field columns; it
round-trips through the importer. Cells are formula-injection safe.

The Contacts page gets an Import dialog (CSV parsed in the browser,
column mapping pre-filled from common export headers, chunked upload,
report) and an Export button.

Refs #38, #79
Team ESPs (BYO SES, Postmark, ...) had no daily cap; the existing quotas
only cover shared organization grants. An ESP can now set
dailySendLimit: broadcast and sequence emails over it in a UTC day are
deferred to just after midnight instead of failing, while
transactional mail is never held back. The count is kept in Redis per
ESP and day, and the ESP list shows today's progress.

Campaign mail that hits an organization grant's daily or monthly quota
is now deferred until the quota resets instead of counting as a failed
attempt (which eventually marked the contact failed).

Adds a docs page on warming up a domain or SES account (when you don't
need to, SES sandbox and quotas, a ramp table, engaged readers first,
what to monitor), linked from getting started, the SES page and sending
settings.

Refs #67, #79
Implements the revised hosted plans from #60 (ADR 0003): Free, Starter,
Pro and Business are priced on sends and teams with unlimited contacts.
Monthly org-wide send caps are 3,000 / 25,000 / 125,000 / 600,000 and
team limits 1 / 1 / 3 / 15; fair use is replaced by hard caps whose
errors name the next plan up. The Starter footer stays on.

starter is added to the billing config (regenerated schema and a
migration that widens the plan checks), the catalog
(BILLING_STARTER_*, <PROVIDER>_STARTER_*_PRODUCT_ID), the contract, MCP
and the dashboard. The paid plan list now lives once in the contract.
The public catalog also returns each plan's limits so the dashboard no
longer hard-codes them. Organizations already on Pro or Business with
more teams than the new limits keep them through a teams limit
override. The fake billing provider is seeded from the configured
catalog.

Pricing docs are rewritten for the new table.

Refs #66, #60, #79
The previous commit set fairUse to false everywhere, but that flag also
gates the new-organization marketing ramp and the automatic
bounce/complaint reputation controls, so both were switched off in
cloud. fairUse now means "cloud safeguards apply" and is on for Free,
Starter, Pro and Business.

Campaign sends that hit the daily ramp are deferred to the next UTC day
instead of failing the contact after three attempts.

Refs #66, #73, #79
Cloud-only controls (ABUSE_CONTROLS=off disables them; OSS never runs
them), worked by operators through a review queue (abuse_flags) and a
CLI, bun scripts/abuse.ts (queue, show, release, confirm, suspend-org,
unsuspend-org, pacing-exempt). Alerts go to OPERATOR_ALERT_EMAIL.

- Signup (#71): Better Auth rejects disposable domains (the
  disposable-email-domains dataset) before a code is sent, applies a
  free-provider policy (allow/flag/block), caps new accounts per client
  IP per day (Redis; the IP comes from Express's req.ip), and can score
  signups with Exa; high-risk accounts start with sending paused.
- Content (#72): broadcasts, sequences, test sends and edits to running
  sequences are scanned (heuristics, plus Claude when configured).
  Blocked content pauses the team; doubtful content is held for review.
  Verdicts are stored per content hash; approval lets it through.
- New accounts (#73): for the first 30 days marketing sends are paced
  hourly on top of the daily ramp; deferred, never failed. The broadcast
  page shows the pace and ETA; operators can exempt a vetted org.
- Imports (#74): every import is in the audit log; large imports alert
  operators, and a new account's campaigns wait for review.

The acceptable-use page now reserves the right to review and suspend
and describes these controls.

Refs #75, #71, #72, #73, #74, #79
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.

Signup guards for hosted: block disposable/free emails, cap signups per IP per day, optional real-person check

2 participants