Anti-abuse engine for platforms that send email

An illustration of Catraca at work: a turnstile rotor lets email events through one at a time. Waiting events are open rings on the left. Each one leaves on the right filled with the colour of its decision: most are allowed (green); a few are flagged (blue), throttled (amber) after a short wait, held (violet) in a bay below the lane for a person to review, or blocked (red) and dropped. A dashed ring around an allowed event marks a shadow rule that matched without changing the decision.
  • allow
  • flag
  • throttle
  • hold
  • block
  • shadow

Your platform asks Catraca before each send. It answers with an action and the rules behind it in a millisecond or two, so you can stop a bought list or a leaked API key before it spends the reputation every other sender depends on.

Open source under Apache-2.0. Self-hosted, written in Go.

Run it with Redis and Postgres
git clone https://github.com/guilhermebsantiago/catraca
cd catraca/deploy
export CATRACA_API_TOKEN=$(openssl rand -hex 32)
export CATRACA_FEEDBACK_SECRET=$(openssl rand -hex 32)
docker compose up --build

Then curl localhost:8080/readyz. Without Docker: go build -o catraca ./cmd/catraca.

One abuser can get everyone’s mail paused

A platform that sends email for its customers shares one reputation among them: its IP addresses, its domains, its account with the email provider. When one customer mails a bought list, or a leaked API key starts sending spam, bounces and complaints rise for the whole platform.

Providers watch those rates. Gmail and Yahoo ask bulk senders to keep spam complaints under 0.3%. Amazon SES puts an account under review when its bounce rate reaches 5%, and can pause its sending if the rate keeps climbing. When that happens, the abuser's mail stops, and so does everyone else's.

Catraca moves the check to before the send. It looks for the patterns that come first, such as a day-old account sending at burst volume or a key used from dozens of new addresses, and gives your platform a decision while the email is still yours to stop.

Watch it decide

45 seconds of a real Catraca server playing a scripted scenario. Every request, response, webhook and report on screen is its actual output.

What the video shows
  1. The problem: email providers pause sending when bounce or complaint rates climb, so one abuser on a shared account can stop mail for every customer.
  2. Every send asks Catraca first: an ordinary send comes back allow.
  3. A brand-new account sends 140 emails in a minute. From send 101 thenew-account-burst rule answers throttle withRetry-After: 60 (enforced here for the demo; it ships in shadow mode).
  4. Complaints arrive through the signed feedback endpoint, and that account's next sends are held for review.
  5. An API key is reported leaked. The next send with it is blocked, and the platform receives a signed revoke_key webhook that verifies.
  6. Shadow mode: reviewers label decisions, and catraca report shows each rule's precision with its Wilson lower bound, one shadow rule ready to promote and one enforced rule to retire.
  7. Catraca is open source under Apache-2.0, on GitHub and at catraca.guilhermebs.com.br.

Ask before each send

One call, inside the request that sends. Catraca records the event in its counters, reads the signals back in one store round trip, runs every rule, and answers with the most severe action among the enforced rules that matched, and the reasons.

POST /v1/decide
curl -s -X POST "$CATRACA/v1/decide" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "send",
    "account_id": "acct_42",
    "api_key_id": "key_7d3e",
    "ip": "203.0.113.7",
    "from": "hello@shop.example",
    "to": ["ana@mail.example"],
    "subject": "Your receipt",
    "links": ["https://shop.example/r/1"]
  }'
Response: allow
{
  "decision": {
    "id": "01M50QGN32570CX37SPKGJDZ7A",
    "event_id": "01M50QGN32570CX37SPKGJDZ79",
    "time": "2026-10-15T21:29:27.394212Z",
    "action": "allow",
    "ruleset_version": "098086f42c27",
    "latency_us": 182
  }
}
Response: throttle
HTTP/1.1 200 OK
Content-Type: application/json
Retry-After: 60

{
  "decision": {
    "id": "01M50QGQK25X1NM2J4XR59GJW8",
    "event_id": "01M50QGQK25X1NM2J4XR59GJW7",
    "time": "2026-10-15T21:29:29.9547537Z",
    "action": "throttle",
    "retry_after": 60,
    "matches": [
      {
        "rule_id": "new-account-burst",
        "reason": "New account sending at burst volume; slow down and retry later",
        "action": "throttle",
        "mode": "enforce",
        "retry_after": 60
      }
    ],
    "ruleset_version": "098086f42c27",
    "latency_us": 211
  }
}
Response: block
{
  "decision": {
    "id": "01M50QGQP8YVGSY487W3YJ8J24",
    "event_id": "01M50QGQP8YVGSY487W3YJ8J23",
    "time": "2026-10-15T21:29:30.0567392Z",
    "action": "block",
    "matches": [
      {
        "rule_id": "leaked-key-send",
        "reason": "This API key was found in public and must be rotated before it can send again",
        "action": "block",
        "mode": "enforce",
        "effects": ["revoke_key"]
      }
    ],
    "ruleset_version": "098086f42c27",
    "latency_us": 240
  }
}

Act on the action

Act on action alone; matches explain it. Every decision also lists the shadow rules that matched and the version of the ruleset that decided.

What the platform does with each action
ActionWhat your platform doesThe Hono example answers
allowSend it.200, sent
flagSend it, and record it for review.200, sent and logged
throttleReject this attempt; retry after retry_after.429 with Retry-After
holdQueue the email until a person reviews it.202, queued for review
blockRefuse it, and show the reason.403 with the reasons

What happens in a decision

  1. Validate the event and give it an id.
  2. Observe its signals: record the event in the counters and read them back in one round trip to Redis or memory, within a 10 ms deadline.
  3. Evaluate every rule. Enforced matches act; shadow matches are only recorded.
  4. Combine: the most severe enforced action wins, from allow through flag, throttle and hold to block.
  5. Record the decision in the audit log, and call the webhooks of enforced effects.

Feedback and account activity go through the same engine with POST /v1/events. See the HTTP API.

Rules earn the right to enforce

A false positive is a bug. Every rule starts in shadow mode: it runs on every event and is recorded with the decision, but it never changes the action. It enforces only once its measured precision clears a gate, and every reversal of an enforced decision counts against it.

  1. Write it in shadow mode

    With fixtures that show where it matches and where it just misses.

    catraca rules test
  2. Backtest it

    On labeled events, through the same engine, to tune its thresholds.

    catraca replay
  3. Ship it and label its matches

    Reviewers work through its matches; when the platform reverses a decision after an appeal, that is afalse_positive.

    GET /v1/rules/{id}/review
  4. Promote it once it passes

    In CI, the gate fails any enforced rule below it, unless an override records who decided and why.

    catraca rules gate

Try the gate

At least 30 labeled matches, and a Wilson 95% lower bound on precision of at least 0.90. Five out of five is 100% on almost no evidence; the lower bound says how sure you can be.

30
0
new-account-burstshadow
Precision
1.000
95% interval
[0.886, 1.000]
Status
below_precision

Every label agrees, but 30 labels cannot show 90% precision with 95% confidence. 35 of 35 can.

catraca report
Gate: at least 30 labeled matches and a precision lower bound of 0.90 (Wilson 95%)

RULE               MODE     MATCHES  LABELED  TP   FP  PRECISION  95% CI          STATUS
leaked-key-send    enforce  212      61       61   0   1.000      [0.941, 1.000]  passes
new-account-burst  shadow   1840     120      117  3   0.975      [0.929, 0.991]  passes
shortener-links    enforce  950      80       66   14  0.825      [0.727, 0.893]  below_precision (override)
complaint-spike    shadow   33       9        9    0   1.000      [0.701, 1.000]  insufficient_labels

Promote candidates (shadow rules that pass the gate):
  new-account-burst: precision 0.975 [0.929, 0.991] on 120 labeled matches

Retire candidates (enforced rules below the gate):
  shortener-links: precision 0.825 [0.727, 0.893] on 80 labeled matches

catraca report lists shadow rules that pass as promote candidates and enforced rules below the gate as retire candidates. The same numbers are at GET /v1/stats/rules. See the audit log and the precision gate.

Rules are code, kept in git

A ruleset is one YAML file of CEL expressions over signals. It is compiled and type-checked when it loads, tested with fixtures, versioned by a hash that every decision records, and reloaded without a restart. A file with an error never replaces the ruleset that is serving.

rules/default.yaml
  - id: complaint-rate-high
    description: >-
      Holds sends from accounts whose spam complaint rate over the last 24
      hours is above 0.3%, the ceiling Gmail and Yahoo set for bulk senders.
      It needs at least 1,000 sends in the window so that a couple of
      complaints on a small account do not trip it.
    when: >-
      event.type == "send"
      && account.sends_24h >= 1000
      && account.complaint_rate_24h > 0.003
    action: hold
    reason: Spam complaint rate above 0.3% in the last 24 hours
    tags: [reputation]

Signals

Counters over sliding windows of event time, in Redis or in memory, read in one round trip. Every count includes the event being decided.

event
type recipients to_domains link_domains subject_length attributes
account
sends_1m sends_24h complaint_rate_24h hard_bounce_rate_24h age_hours volume_ratio_1h recent_ip
key
sends_1m distinct_ips_1h new_ip age_hours leaked
ip
sends_1h distinct_accounts_24h signups_24h
domain
sends_1h distinct_accounts_24h complaints_24h

Plus in_list, any_in_list, domain_of and ratio, and the standard CEL library. All signals, writing rules.

The starter ruleset

Nine rules for platforms that send email. All but one start in shadow mode: a key reported leaked is a fact, not a guess, so leaked-key-send enforces from day one.

RuleActionModeCatches
leaked-key-sendblockenforceA send with a key reported leaked; asks the platform to revoke it
new-account-burstthrottleshadowAn account under a day old sending over 100 emails a minute
complaint-rate-highholdshadowSpam complaints above 0.3% in 24 hours, over at least 1,000 sends
hard-bounce-rate-highholdshadowHard bounces above 5% in 24 hours: a bought, scraped or stale list
key-many-ipsholdshadowA key used from 20 or more IP addresses in an hour, from a new one
account-takeover-volumeholdshadowAn account over 30 days old sending over 10 times its usual hourly volume from a new IP
shortener-links-new-accountflagshadowAn account under 3 days old linking through a URL shortener
signup-farm-ipflagshadowThe fifth signup or more from one IP address in 24 hours
recipient-domain-sprayholdshadowAn account under a week old mailing 200 or more recipients an hour, spread over many domains

Feedback in, effects out

Catraca learns from what happens after the send, and asks your platform to act when an enforced rule says so. Every route a provider calls is authenticated by its signature, and a request that fails it is refused before its body is read.

Who calls which route, and how each is authenticated
CallerRouteAuthenticated by
Your platformPOST /v1/decide, POST /v1/eventsBearer token
Amazon SES, through SNSPOST /v1/feedback/sesSNS message signature
useSendPOST /v1/feedback/usesendX-UseSend-Signature
Any other providerPOST /v1/feedbackCatraca-Signature
GitHub secret scanningPOST /v1/secret-scanning/githubGitHub's ECDSA signature
Catraca, to your platformeffect hooks, the key resolverCatraca-Signature
Prometheus and probesGET /metrics, /healthz, /readyznone: keep them private

Amazon SES

Bounces, complaints and deliveries arrive through SNS. Catraca verifies the SNS signature with a certificate it fetches only from sns.<region>.amazonaws.com over HTTPS, confirms the subscription for the topics you allow, and reads the account from a message tag or a header.

Set up SES feedback

useSend

The open-source email platform's webhooks, verified with X-UseSend-Signature and a five-minute window. Bounced, complained and delivered emails become feedback events, one per recipient.

Set up useSend feedback

GitHub secret scanning

Catraca is the partner endpoint for your key format. It verifies GitHub's signature, asks your resolver which API key a token is by its SHA-256, and records a key_leak. The raw token is never logged or stored.

Join the partner program

Signed effect hooks

An enforced rule's effects, such as revoke_key, call your webhooks, signed with HMAC-SHA256, written to a durable outbox first, retried with backoff and idempotent per decision and effect.

Verify a hook

From TypeScript

A typed client with no dependencies, and a Hono email API that asks before every send. With failOpen, an unreachable or slow Catraca returns allow and the mail goes out; a bad token or an invalid event still throws, so a misconfiguration never turns checks off quietly.

examples/client-ts
const catraca = new Catraca({
  baseUrl: "http://catraca.internal:8080",
  token: process.env.CATRACA_TOKEN!,
  timeoutMs: 250,
  failOpen: true,
});

const decision = await catraca.decide({
  type: "send",
  account_id: "acct_42",
  api_key_id: "key_1",
  ip: "203.0.113.7",
  from: "hello@shop.example",
  to: ["ana@mail.example"],
  subject: "Your receipt",
});

switch (decision.action) {
  case "allow":
  case "flag":
    // send it; for flag, also log decision.id for review
    break;
  case "throttle":
    // answer 429 with Retry-After: retryAfterSeconds(decision)
    break;
  case "hold":
    // queue it until a person reviews it
    break;
  case "block":
    // refuse it, citing reasons(decision)
    break;
}

To your platform

Verify the signature with the public Go package catraca/signature, or in any language: an HMAC-SHA256 of the timestamp and the raw body.

An effect hook (abridged)
POST /hooks/catraca/revoke-key HTTP/1.1
Content-Type: application/json
Catraca-Signature: t=1772366400,v1=5d9c1c2f0e0b7a6c...
Catraca-Idempotency-Key: 01JNQX8R3T0B6M9X1W2V4Y5Z7A:revoke_key
Catraca-Attempt: 1

{
  "effect": "revoke_key",
  "decision_id": "01JNQX8R3T0B6M9X1W2V4Y5Z7A",
  "account_id": "acct_42",
  "api_key_id": "key_7d3e",
  "action": "block",
  "rule_ids": ["leaked-key-send"]
}

When something breaks, mail keeps flowing

A broken anti-abuse service must not stop every good sender. Each part of Catraca degrades detection instead of blocking traffic.

The store is down. Decisions nearest the rotor were made from the event alone and carry the steel dot of a degraded decision; they were allowed. The key at the arm was reported leaked before the outage, so it is still blocked.
  • Every rule says how it fails

    on_error: open skips a rule that cannot be evaluated. closed counts it as matched: its action applies, its effects never run, so an outage cannot revoke every key.

  • A deadline on every lookup

    Signals are read within 10 ms. When the store is slow or down, the decision is made without them and marked degraded.

  • Known leaked keys stay blocked

    Each process remembers up to 10,000 leaked keys, so leaked-key-send can fail open without letting them send.

  • The client fails open

    Unreachable, slow or answering 5xx: the platform sends anyway and logs fail_open. A 4xx still throws, so a bad token is found, not ignored.

  • A broken ruleset never ships

    A reload that fails leaves the current ruleset serving. A server that starts without a valid one stays up, not ready, until a reload succeeds.

  • The audit log never slows a send

    Decisions queue in memory and are written in batches. If the database falls behind, decisions are dropped from the log rather than slowing sends, and every drop is counted.

Fast enough for the send path

A decision is one store round trip plus CEL evaluation. The engine itself decides in 3.5 µs with the default ruleset (Go benchmark); the rest of a request is HTTP, JSON and handing the decision to the audit log.

POST /v1/decide, round trip: in-memory counters, SQLite audit log

Latency of POST /v1/decide at three request rates, in-memory counters, SQLite audit log
Loadp50p99Failuresp50 to p99, on a scale from 0 to 3 ms
1,000 req/s0.24 ms0.56 ms0
2,000 req/s0.26 ms0.73 ms0
4,000 req/s0.31 ms0.88 ms0
8,000 req/s0.49 ms1.27 ms0

POST /v1/decide, round trip: Redis 7.4 counters, SQLite audit log

Latency of POST /v1/decide at three request rates, Redis 7.4 counters, SQLite audit log
Loadp50p99Failuresp50 to p99, on a scale from 0 to 3 ms
250 req/s0.95 ms1.69 ms0
500 req/s1.41 ms2.09 ms0
1,000 req/s2.21 ms2.97 ms0

Measured on a Ryzen 5 7600X with an open-loop load generator on the same machine: requests start on schedule whether or not earlier ones finished, so a slow server shows up as latency instead of a lower rate. Run it yourself with bench/load. The Redis figures come from the Windows build of Redis 7.4 on the same machine, so read them as indicative; one Redis core handles about 2,000 decisions a second. Method, Go benchmarks and caveats are in docs/benchmark.md.

One binary, on your infrastructure

Catraca is a single Go binary, built without cgo, and a container image that runs as a non-root user. One YAML file configures it, secrets come from the environment, and the audit log keeps no message content unless you ask it to.

Pick a shape
PartOne instanceSeveral instances
CountersIn memory. Nothing to run; they start over on restart.Redis 6.2 or later, shared by every instance.
Audit logSQLite, created on first start. Pure Go, in WAL mode.Postgres. The webhook outbox lets every instance deliver without sending twice.
Start withcatraca servedocker compose up in deploy/
  • /healthz and /readyz for probes; readiness checks the ruleset, the store and the audit database.
  • /metrics for Prometheus: decisions by action, matches by rule and mode, degraded decisions, latency.
  • JSON logs, one line per request. Tokens, secrets and message content are never logged.
  • Reload rules with POST /v1/rules/reload or SIGHUP; decisions in flight finish on the ruleset they started with.
catraca.yaml for several instances
auth:
  tokens:
    - name: platform
      token: ${CATRACA_API_TOKEN}

store:
  backend: redis
  redis:
    url: ${CATRACA_REDIS_URL}   # redis://redis:6379/0

audit:
  dsn: ${CATRACA_AUDIT_DSN}     # postgres://user:pass@host:5432/catraca

Every field is in the configuration reference; the compose stack runs Catraca with Redis and Postgres.

Validate before you deploy
$ catraca config check
catraca.yaml is valid (1 warning(s))
  listen      :8080
  api tokens  platform
  ruleset     rules/default.yaml (version 098086f42c27, 9 rules: 1 enforced, 8 shadow)
  store       memory (one instance; counters are lost on restart)
  audit       sqlite:catraca.db, minimal events
  ...

Questions

Something else? Open an issue or read the design.

Does Catraca send email?

No. Your platform keeps sending through its own provider or MTA. Catraca decides whether a send should go out, and learns from the bounces, complaints and account activity your platform reports.

What if Catraca is down?

Send anyway. A broken anti-abuse service must not stop every good sender, so the TypeScript client fails open after a timeout you choose (250 ms in the examples), and logs that it did, so the outage is visible.

Does it store my customers’ email?

Not by default. The audit log keeps the account, key and IP, the sender domain, the number of recipients and their domains, and a hash of the subject: no addresses, no subject text, no body. A full mode keeps the whole event when your privacy policy allows it.

Can I use it with my email provider?

Any platform that can make an HTTP call before it sends can ask Catraca. Delivery feedback has adapters for Amazon SES and useSend; any other provider can post feedback to /v1/feedback, signed with a shared secret.

Why CEL for rules?

CEL expressions are typed, safe to evaluate and cannot loop forever, and the ones that iterate have a cost limit. Catraca compiles every rule when the ruleset loads, so a syntax error, a type error or an unknown list fails the load instead of a send, and lint flags signals that do not exist.

How do I tune the thresholds?

Replay labeled events with catraca replay to see each rule’s precision and recall, then watch the rule in shadow mode on real traffic. The starter ruleset’s numbers are a starting point, explained in each rule’s description.

Is it ready for production?

Catraca is in early development. Everything described here works and is tested, in CI too, against real Redis and Postgres. The API and the configuration may still change before a first release.

What does the name mean?

Catraca is Portuguese for turnstile: it lets people through one at a time and stops the ones who should not pass. The word also names a ratchet, the wheel that only turns one way, a notch at a time.