Skip to content

fix(email): enforce shared send throttling - #1417

Open
seonghobae wants to merge 8 commits into
developfrom
fix/email-shared-send-rate-limit
Open

fix(email): enforce shared send throttling#1417
seonghobae wants to merge 8 commits into
developfrom
fix/email-shared-send-rate-limit

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • replace the process-local /api/emails/send throttle with a PostgreSQL-backed per-scope bucket
  • serialize check-and-record decisions with a transaction advisory lock and fail closed when shared state is unavailable
  • record non-sensitive allow/quota audit events and add focused concurrent-worker, isolation, expiry, and endpoint contract tests

Closes #1379

Verification

  • uv run --project backend --group dev pytest -q backend/tests/test_email_send_rate_limiter.py backend/tests/test_emails_api.py backend/tests/test_alembic_migrations.py
  • uv run --project backend --group dev ruff check backend/services/email_send_rate_limiter.py backend/db/models.py backend/api/emails.py backend/alembic/versions/0018_email_send_rate_buckets.py backend/tests/test_email_send_rate_limiter.py backend/tests/test_emails_api.py

Open in Devin Review

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 59 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cb19da00-19c0-48f0-b6cf-0ae691bb9291

📥 Commits

Reviewing files that changed from the base of the PR and between f3beb1c and 2d4ec7c.

📒 Files selected for processing (24)
  • .cursor/environment.json
  • .cursor/install.sh
  • .cursor/start.sh
  • AGENTS.md
  • ARCHITECTURE.md
  • CHANGELOG.md
  • CLAUDE.md
  • backend/alembic/versions/0011_email_read_state.py
  • backend/alembic/versions/0018_email_send_rate_buckets.py
  • backend/alembic/versions/0019_email_record_read_state.py
  • backend/api/emails.py
  • backend/db/models.py
  • backend/scripts/bootstrap_db.py
  • backend/scripts/reconcile_local_postgres_role.py
  • backend/services/email_import_service.py
  • backend/services/email_send_rate_limiter.py
  • backend/tests/test_alembic_migrations.py
  • backend/tests/test_bootstrap_db.py
  • backend/tests/test_cloud_agent_environment.py
  • backend/tests/test_email_import_quota_lock_key.py
  • backend/tests/test_email_send_rate_limiter.py
  • backend/tests/test_emails_api.py
  • backend/tests/test_reconcile_local_postgres_role.py
  • docs/development/cloud-agent-environment.md

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.

@seonghobae

Copy link
Copy Markdown
Contributor Author

@opencode-agent @cwl-noema-review please independently review exact current head 3d08f5fe829e5111d6bd3e641e5272ad6ad74ccb. All source/security/coverage checks are passing; only metadata-only gate remains pending and the ruleset still requires one approving review. Validate shared send-throttling behavior, current-head checks, and security scope before approving.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Please perform an independent review for exact current head 3d08f5f. Review only the current diff and current-head checks; do not dismiss reviews, bypass branch protection, update the base, or merge.

@seonghobae

seonghobae commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author
\nPlease independently review exact current head for PR #1417. all current checks are terminal with no failures; review the shared send-throttling diff and concurrency/security behavior at this exact SHA. Publish normal structured review evidence; do not reuse stale reviews, dismiss, bypass, or merge.

@seonghobae
seonghobae enabled auto-merge (squash) August 19, 2026 13:59
@seonghobae

Copy link
Copy Markdown
Contributor Author

@opencode-agent @cwl-noema-review Review-only request for exact current head 3d08f5f. I traced every email send endpoint caller into the shared PostgreSQL advisory-lock bucket and verified the focused rate-limiter tests (64 passed, 1 skipped in prior local evidence), with current hosted checks green. Please independently review this exact SHA only; no bypass or self-approval.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

OpenCode cannot approve yet because required coverage evidence did not pass.

Review outcome

1. HIGH .github/workflows/opencode-review.yml:1 - Coverage evidence did not prove required test/docstring evidence

  • Problem: The required coverage-evidence job result was failure, so OpenCode cannot establish approval sufficiency for this head.

  • Root cause: Automated approval is only valid when the same-head coverage-evidence job proves supported repository test suites passed and configured docstring gates passed or were advisory, or reports not applicable because no supported source files or package manifests exist. Missing, failed, skipped, unavailable, or unsupported-tooling test evidence is a blocker.

  • Fix: Install or configure the repository test/docstring evidence tooling when source files or package manifests exist, rerun the current-head coverage-evidence job, and approve only after it reports success with required evidence or explicit no-source not-applicable evidence.

  • Regression test: Keep the approval branch checking needs.coverage-evidence.result == success before posting APPROVE, and publish REQUEST_CHANGES when coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence are present.

  • Result: REQUEST_CHANGES

  • Reason: coverage-evidence result was failure, so required test/docstring evidence was not proven for current head 3d08f5fe829e5111d6bd3e641e5272ad6ad74ccb.

  • Head SHA: 3d08f5fe829e5111d6bd3e641e5272ad6ad74ccb

  • Workflow run: 32255702926

  • Workflow attempt: 1

Coverage evidence

Coverage evidence job did not run or did not publish coverage evidence.

Changed-File Evidence Map

flowchart LR
  PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
  Evidence --> S1["Backend (6 files)"]
  S1 --> I1["API and service runtime"]
  I1 --> R1["Review risk: Backend (6 files)"]
  R1 --> V1["backend tests"]
Loading

@opencode-agent

Copy link
Copy Markdown
Contributor

OpenCode Review Overview

  • Head SHA: 3d08f5fe829e5111d6bd3e641e5272ad6ad74ccb
  • Workflow run: 32255702926
  • Workflow attempt: 1
  • Gate result: REQUEST_CHANGES (approval step)

Pull request overview

OpenCode cannot approve yet because required coverage evidence did not pass.

Review outcome

1. HIGH .github/workflows/opencode-review.yml:1 - Coverage evidence did not prove required test/docstring evidence

  • Problem: The required coverage-evidence job result was failure, so OpenCode cannot establish approval sufficiency for this head.

  • Root cause: Automated approval is only valid when the same-head coverage-evidence job proves supported repository test suites passed and configured docstring gates passed or were advisory, or reports not applicable because no supported source files or package manifests exist. Missing, failed, skipped, unavailable, or unsupported-tooling test evidence is a blocker.

  • Fix: Install or configure the repository test/docstring evidence tooling when source files or package manifests exist, rerun the current-head coverage-evidence job, and approve only after it reports success with required evidence or explicit no-source not-applicable evidence.

  • Regression test: Keep the approval branch checking needs.coverage-evidence.result == success before posting APPROVE, and publish REQUEST_CHANGES when coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence are present.

  • Result: REQUEST_CHANGES

  • Reason: coverage-evidence result was failure, so required test/docstring evidence was not proven for current head 3d08f5fe829e5111d6bd3e641e5272ad6ad74ccb.

  • Head SHA: 3d08f5fe829e5111d6bd3e641e5272ad6ad74ccb

  • Workflow run: 32255702926

  • Workflow attempt: 1

Coverage evidence

Coverage evidence job did not run or did not publish coverage evidence.

Changed-File Evidence Map

flowchart LR
  PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
  Evidence --> S1["Backend (6 files)"]
  S1 --> I1["API and service runtime"]
  I1 --> R1["Review risk: Backend (6 files)"]
  R1 --> V1["backend tests"]
Loading

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 2d4ec7cf1dabb6f3f2ad572f3b1a4d8ca4cdaba5:

  • 5 unresolved current review thread(s) remain.

@opencode-agent
opencode-agent Bot disabled auto-merge August 19, 2026 22:51
@seonghobae

seonghobae commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Current-head review request for PR #1417.

HEAD: 3d08f5fe829e5111d6bd3e641e5272ad6ad6

The shared email-send limiter was audited at the exact HEAD: every send caller reaches the PostgreSQL advisory-lock bucket, unavailable shared state fails closed, scope keys bind organization and user, and audit records contain only a one-way scope hash. Focused tests: 64 passed, 1 skipped. Full backend: 1777 passed, 32 skipped. Ruff and diff checks pass.

Please review this exact HEAD with current GitHub Checks and provide structured adversarial evidence. Do not reuse the older coverage-only review.

…1381)

* fix(db): make fresh-database schema bootstrap work end-to-end

The retired 'emails' table (replaced by 'email_records' during the email
model reconciliation) was still referenced by fresh-DB setup, breaking both
'alembic upgrade head' and bootstrap_db against a clean database:

- schema_backfill_sql() created a dead 'ix_emails_owner_date ON emails' index
  (used by migration 0001 and bootstrap_db) -> UndefinedTableError.
- migration 0011_email_read_state did 'ALTER TABLE emails ADD COLUMN is_read'
  unconditionally; guard it on the table existing (matching the has_table/
  has_column pattern used by later revisions) since email_records already
  carries is_read from the model metadata.
- give email_records.is_read a server_default so create_all/bootstrap_db match
  the migration intent and raw inserts that omit is_read (postgres smoke seeds)
  don't hit a NOT NULL violation.

Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>

* fix(email-import): avoid NUL byte in Postgres advisory-lock key

The owner import quota advisory lock built its owner key as
f'{user_id}\x00{organization_id}' and passed it to hashtext() as a text bind
param. PostgreSQL text cannot encode NUL (0x00), so every email import 500'd on
real Postgres with CharacterNotInRepertoireError (mocked/SQLite unit tests skip
the advisory-lock path, hiding it). Derive a NUL-free sha256 digest instead and
update the tests to assert the NUL-free contract.

Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>

* chore(env): add Cloud Agent dev environment (backend + frontend + Postgres/pgvector)

Repo-managed .cursor/environment.json plus idempotent install/start scripts:
- install.sh: system packages (postgresql-16 + pgvector, python venv/build
  tools), backend venv + pinned requirements, frontend pnpm@11.5.3 deps.
- start.sh: bring up the Postgres cluster, generate a per-VM dev .env with
  random secrets on first boot, ensure the app DB + pgvector extension, and
  apply alembic migrations.
- terminals run the backend (start_backend.py) and frontend (next dev).

Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>

* fix(env): keep Cloud Agent Postgres secrets off the psql command line

Reject empty DATABASE_URL role secrets and apply ALTER USER through
dollar-quoted psql stdin. Install hashed requirements so the baked
environment matches the CI supply-chain contract.

Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>

* fix(db): guard email_records.is_read on the alembic path

Keep 0011 as a retired-emails no-op downgrade and add 0018 so existing
email_records tables get NOT NULL DEFAULT true without interpolated DDL.

Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>

* docs: record Cloud Agent env contract and NUL advisory-lock anti-pattern

Pin the import quota lock key to an independent SHA-256 digest and point
operators at the next boot/import action.

Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>

* test(cloud-agent): reject unpinned pip self-upgrade

* fix(cloud-agent): remove unpinned pip self-upgrade

* security(cloud-agent): document fixed-argv subprocess boundary

* fix(db): stack read-state migration after send buckets

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Co-authored-by: Seongho Bae <me@seonghobae.me>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
@seonghobae
seonghobae enabled auto-merge (squash) August 20, 2026 10:45
@opencode-agent
opencode-agent Bot disabled auto-merge August 20, 2026 11:48
@seonghobae
seonghobae enabled auto-merge (squash) August 20, 2026 11:53
@seonghobae

Copy link
Copy Markdown
Contributor Author

The only OpenCode blocker is stale coverage evidence for prior head 3d08f5fe829e5111d6bd3e641e5272ad6ad74ccb; current head is ae254c127eea838f19e4da59074d12e3a15a3c62. The current branch has already passed its focused shared-send throttling tests (123 passed, 2 skipped). Requested a fresh current-head review and required Checks; no source change was justified by stale evidence.

Copy link
Copy Markdown
Contributor Author

Exact current HEAD ae254c127eea838f19e4da59074d12e3a15a3c62 replaces the process-local email-send throttle with the shared PostgreSQL advisory-lock bucket. Focused exact-head tests passed (test_email_send_rate_limiter.py and test_threading_service.py: 24 passed); hosted source/security/build workflows are successful. Please publish current-head OpenCode/Noema review evidence; do not reuse the stale coverage review for 3d08f5fe.

@devin-ai-integration devin-ai-integration 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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@seonghobae

Copy link
Copy Markdown
Contributor Author

Current-head review request for PR #1417.

Exact head: 69fb72d30c71ab7a9c2c6e09413292a05278148d
Exact base: 81c105645ca6e680f5f8c15ba9c33b67eb63c48b

The live review threads are resolved/outdated for this head. Please regenerate independent review and required metadata evidence against this exact head. Any new finding will be handled before normal protected merge; no bypass or force operation is requested.

@OpenCode review current head 69fb72d. @coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

@seonghobae: I will review PR #1417 against current head 69fb72d30c71ab7a9c2c6e09413292a05278148d and base 81c105645ca6e680f5f8c15ba9c33b67eb63c48b. The review will assess the current diff, shared throttling behavior, concurrency behavior, and security scope. It will not bypass protection rules or merge the PR.

⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@seonghobae
seonghobae dismissed opencode-agent[bot]’s stale review August 22, 2026 07:43

Stale review: cited a coverage-evidence/required-check failure on an earlier commit; current head has been verified (gh pr checks) to pass coverage-evidence and all other non-metadata-gate required checks, with no current-head review from this reviewer. Dismissing as superseded per AGENTS.md stale-review guidance.

@opencode-agent opencode-agent Bot added priority: medium Normal-priority or P2 work status: needs-review Open pull request requiring current-head review or checks labels Aug 22, 2026
@opencode-agent opencode-agent Bot added the type: bug Defect or incorrect behavior label Aug 22, 2026
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

An error occurred during the review process. Please try again later.


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.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Exact-head maintenance evidence

  • Repository: ContextualWisdomLab/naruon
  • Pull request: #1417
  • Exact head SHA: 46f4b92a717361e3e4e42fcebc1d8c090a64c59b
  • Exact base SHA: e5e99b4e3bb081b92c602358878856536030e2ca
  • Root cause fixed: the real PostgreSQL bootstrap smoke seed omitted the now-required email_records.is_read value. A model/server default cannot alter an already-created table, so the raw seed now supplies the explicit read-state value.
  • Verification: uv run pytest -q over the email throttling/import/migration/bootstrap/reconcile/cloud-agent suites (180 passed); targeted Ruff; Python compile; git diff --check.
  • Review disposition: no unresolved current non-outdated review threads. The exact-head smoke failure was reproduced and fixed without changing runtime throttling behavior.
  • Hosted state: required Checks were recreated for this exact head and remain queued; protected auto-merge is enabled, but no hosted pass or merge is claimed.
  • Decision: WAIT_AND_REMEDIATE until exact-head required Checks and lawful review evidence complete. No bypass, direct push, or force push.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 5 new potential issues.

Open in Devin Review

Comment on lines +53 to +68
def _next_bucket_state(
bucket: EmailSendRateBucket | None,
now: datetime.datetime,
) -> tuple[bool, int, datetime.datetime, datetime.datetime]:
"""Calculate the next state without storing individual attempts."""
expires_at = now + datetime.timedelta(seconds=SEND_RATE_LIMIT_WINDOW_SECONDS)
if bucket is None or now >= bucket.expires_at:
return True, 1, now, expires_at
if bucket.attempt_count >= SEND_RATE_LIMIT_MAX_ATTEMPTS:
return False, bucket.attempt_count, bucket.window_started_at, bucket.expires_at
return (
True,
bucket.attempt_count + 1,
bucket.window_started_at,
bucket.expires_at,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Sliding window replaced by fixed window

The removed limiter enforced a true 60s sliding window via per-attempt timestamps. _next_bucket_state in email_send_rate_limiter.py uses a fixed window anchored at the first attempt, so a scope can send up to ~2x the cap across a boundary (10 near t=59, 10 more at t=61). Standard fixed-window tradeoff, flagged only because it weakens the guarantee the prior code gave.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +164 to +165
await session.commit()
return decision

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Rate limiter commits the shared request session mid-request

enforce_send_email_rate_limit calls session.commit() on the same db session the endpoint uses (email_send_rate_limiter.py). In the current /api/emails/send path this is safe: get_db does not auto-commit, expire_on_commit=False, and all tenant_config fields (including smtp_password) are read into locals before the limiter runs. However, because this is a shared service function, any future caller that has uncommitted work in the session before invoking it would have that work silently committed as a side effect. Worth keeping in mind if this helper is reused elsewhere.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +157 to +164
session.add(
_audit_event(
auth_context,
scope_hash=scope_hash,
decision=decision,
)
)
await session.commit()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Rate limiter writes an audit row + bucket update on every blocked attempt

enforce_send_email_rate_limit (email_send_rate_limiter.py) performs an advisory-lock acquisition, a SELECT ... FOR UPDATE, a bucket UPDATE, and a SecurityAuditEvent INSERT followed by commit() for EVERY call, including quota-exhausted (denied) attempts. This means a caller who is already over quota still forces two writes + a commit per request, so the throttle does not shed database load and security_audit_events grows by one row per send attempt with no cleanup path. The ix_email_send_rate_buckets_expires_at index also suggests an intended expiry-sweep that is never implemented (buckets are reused per-scope so they don't grow unbounded, but the index is currently unused). This appears to be an intentional audit-trail design rather than a correctness bug, but the write amplification on a hot path is worth confirming.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +122 to +146
try:
await session.execute(
select(func.pg_advisory_xact_lock(bindparam("lock_key"))),
{"lock_key": _lock_key(scope_hash)},
)
result = await session.execute(
select(EmailSendRateBucket)
.where(EmailSendRateBucket.bucket_scope_hash == scope_hash)
.with_for_update()
)
bucket = result.scalar_one_or_none()
allowed, attempt_count, window_started_at, expires_at = _next_bucket_state(
bucket, observed_at
)
if bucket is None:
session.add(
EmailSendRateBucket(
bucket_scope_hash=scope_hash,
window_started_at=window_started_at,
attempt_count=attempt_count,
expires_at=expires_at,
created_at=observed_at,
updated_at=observed_at,
)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Duplicate-insert race relies on READ COMMITTED

enforce_send_email_rate_limit serializes per scope via pg_advisory_xact_lock, then SELECTs the bucket. A blocked worker sees the winner's committed row only under READ COMMITTED (the default). Under REPEATABLE READ it would miss the row and hit a PK violation on bucket_scope_hash, failing closed with 503. Safe at default isolation.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +115 to +116
if not _session_uses_postgresql(session):
raise EmailSendRateLimitUnavailable

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Send now fails closed (503) on any non-PostgreSQL session

The previous process-local throttle worked on any database/session; the new limiter raises EmailSendRateLimitUnavailable whenever _session_uses_postgresql is false (email_send_rate_limiter.py), which the endpoint maps to HTTP 503 (emails.py). Any deployment or local/dev environment backed by SQLite (or a session whose bind cannot be introspected) can no longer send email at all. This matches the stated 'fail closed when shared state is unavailable' intent, but it is a behavioral change for non-Postgres environments; the existing send tests only pass because they mock enforce_send_email_rate_limit or return before reaching it.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@opencode-agent
opencode-agent Bot disabled auto-merge August 25, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: medium Normal-priority or P2 work status: needs-review Open pull request requiring current-head review or checks type: bug Defect or incorrect behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(email): enforce send throttling atomically across API workers

1 participant