Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3d08f5f
fix(email): enforce shared send throttling
seonghobae Aug 19, 2026
754cea8
Harden Cloud Agent env secrets and email_records.is_read migration (#…
cursor[bot] Aug 20, 2026
ae254c1
chore: sync send rate limit branch with develop
seonghobae Aug 20, 2026
69fb72d
Merge remote-tracking branch 'origin/develop' into fix/pr1417-current
seonghobae Aug 21, 2026
7125926
Merge branch 'develop' into fix/email-shared-send-rate-limit
seonghobae Aug 25, 2026
46f4b92
test(postgres): seed email read state explicitly
seonghobae Aug 25, 2026
6ae6425
Merge branch 'develop' into fix/email-shared-send-rate-limit
seonghobae Aug 25, 2026
2d4ec7c
Merge branch 'develop' into fix/email-shared-send-rate-limit
seonghobae Aug 26, 2026
48ab340
fix(email): enforce rolling send limit transaction
seonghobae Sep 4, 2026
395a8a8
fix(email): bound rate-limit denial audits
seonghobae Sep 4, 2026
bb7085c
fix(email): pin limiter transaction isolation
seonghobae Sep 4, 2026
ad3ba4e
Merge remote-tracking branch 'origin/develop' into codex/pr1417-current
seonghobae Sep 4, 2026
5a22a26
fix(email): isolate shared send throttle
seonghobae Sep 5, 2026
e22fa78
fix(email): isolate send limits by workspace
seonghobae Sep 5, 2026
6039e1e
test(db): require security audit Alembic revision
seonghobae Sep 5, 2026
5e4e3b9
test(email): pin limiter database-clock and smoke isolation
seonghobae Sep 5, 2026
a30ebfa
fix(db): formalize security audit events migration
seonghobae Sep 5, 2026
8235c42
fix(email): use PostgreSQL clock for send quota
seonghobae Sep 5, 2026
c9b8c84
test(email): require bounded limiter reservation history
seonghobae Sep 5, 2026
7f2091c
fix(email): prune expired limiter reservations
seonghobae Sep 5, 2026
c7b693c
test(email): reproduce bounded-pool send starvation
seonghobae Sep 5, 2026
d1d69d2
fix(email): release request read before limiter session
seonghobae Sep 5, 2026
a9f334a
test(email): preserve send-route session doubles
seonghobae Sep 5, 2026
2e791fb
fix(email): integrate migrated PostgreSQL quota evidence
seonghobae Sep 6, 2026
1666f76
fix(email): inherit nested migration isolation prerequisite
seonghobae Sep 6, 2026
56025b1
fix(email): close pending SMTP sockets on cancellation
seonghobae Sep 6, 2026
dc8b53d
Merge canonical CI signal startup repair into shared send
seonghobae Sep 6, 2026
b7011d2
merge: inherit CI process registration repair
seonghobae Sep 6, 2026
9e4ddde
merge: inherit multiline governance repair
seonghobae Sep 6, 2026
cc2c4cb
fix(email): retain complete CI governance parent repair
seonghobae Sep 6, 2026
5dea509
merge: inherit complete CI owner publisher verification repair
seonghobae Sep 6, 2026
ec3e361
merge(email): inherit validated clean-summary gate repair
seonghobae Sep 6, 2026
489bcbe
merge(email): inherit validated dependency prerequisite
seonghobae Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .cursor/environment.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "Naruon dev (backend + frontend + Postgres/pgvector)",
"user": "ubuntu",
"install": "bash .cursor/install.sh",
"start": "bash .cursor/start.sh",
"terminals": [
{
"name": "backend",
"command": "cd backend && . .venv/bin/activate && python scripts/start_backend.py --host 0.0.0.0 --port 8000"
},
{
"name": "frontend",
"command": "cd frontend && corepack pnpm@11.5.3 dev --port 3000 --hostname 0.0.0.0"
}
],
"ports": [
{ "name": "backend", "port": 8000 },
{ "name": "frontend", "port": 3000 }
]
}
42 changes: 42 additions & 0 deletions .cursor/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Idempotent repository bootstrap for Naruon Cloud Agent environments.
#
# Prepares durable, source-derived state after checkout:
# * system packages (PostgreSQL 16 + pgvector, Python venv/build tooling)
# * the backend virtualenv + pinned Python requirements
# * the frontend pnpm dependency tree
#
# Per-boot service startup (Postgres, schema migrations, dev secrets) lives in
# .cursor/start.sh so it re-runs on every VM boot, including builds.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"

echo "==> [install] system packages (postgresql-16, pgvector, python venv, build tools)"
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq
sudo apt-get install -y -qq \
postgresql-16 \
postgresql-16-pgvector \
postgresql-client-16 \
python3.12-venv \
python3.12-dev \
build-essential

echo "==> [install] backend virtualenv + requirements"
cd "$REPO_ROOT/backend"
if [ ! -x ".venv/bin/python" ]; then
python3 -m venv .venv
fi
# shellcheck disable=SC1091
. .venv/bin/activate
python -m pip install --require-hashes -r requirements-hashes.txt

echo "==> [install] frontend dependencies (pnpm@11.5.3)"
cd "$REPO_ROOT/frontend"
corepack enable
corepack prepare pnpm@11.5.3 --activate
corepack pnpm@11.5.3 install --frozen-lockfile

echo "==> [install] done"
80 changes: 80 additions & 0 deletions .cursor/start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Per-boot runtime reconciliation for Naruon Cloud Agent environments.
#
# Runs on every VM start (idempotent): brings up the PostgreSQL cluster,
# materializes a local dev .env with generated secrets on first boot, ensures
# the app database + pgvector extension exist, and applies Alembic migrations.
#
# Dependency installation lives in .cursor/install.sh; this script only
# reconciles per-boot state and then returns so the backend/frontend terminals
# can start.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"

ENV_FILE="$HOME/.env"
PY="$REPO_ROOT/backend/.venv/bin/python"

echo "==> [start] ensuring PostgreSQL 16 cluster is online"
if ! pg_lsclusters -h 2>/dev/null | awk '{print $4}' | grep -q online; then
sudo pg_ctlcluster 16 main start || true
fi
# Wait for the server to accept connections before touching it.
for _ in $(seq 1 30); do
if sudo -u postgres pg_isready -q; then break; fi
sleep 1
done
if ! sudo -u postgres pg_isready -q; then
echo "==> [start] PostgreSQL did not become ready" >&2
exit 1
fi

echo "==> [start] generating local dev .env on first boot (secrets are per-VM)"
if [ ! -f "$ENV_FILE" ]; then
"$PY" - "$ENV_FILE" <<'PYGEN'
import os, secrets, sys
from pathlib import Path
from cryptography.fernet import Fernet

env_path = Path(sys.argv[1])
db_password = secrets.token_urlsafe(24)
hmac_secret = secrets.token_urlsafe(48)
enc_key = Fernet.generate_key().decode()

env_path.write_text(
"# Naruon local dev environment (generated per-VM; not committed).\n"
f"DATABASE_URL=postgresql+asyncpg://postgres:{db_password}@127.0.0.1:5432/ai_email\n"
f"AUTH_SESSION_HMAC_SECRET={hmac_secret}\n"
f"ENCRYPTION_KEY={enc_key}\n"
"DEBUG=false\n"
"RUNTIME_ENVIRONMENT=development\n"
"ENABLE_PROMETHEUS_METRICS=false\n"
"ALLOWED_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,"
"http://localhost:8000,http://127.0.0.1:8000\n"
"SMTP_MODE=simulated\n"
"OPENAI_API_KEY=\n"
"OPENAI_EMBEDDING_MODEL=text-embedding-3-small\n"
"OPENAI_MODEL=gpt-4o\n",
encoding="utf-8",
)
os.chmod(env_path, 0o600)
print(f"wrote {env_path}")
PYGEN
fi

echo "==> [start] reconciling database role, database, and pgvector extension"
# Keep the local postgres role secret in sync with DATABASE_URL without
# interpolating it into SQL or the process argument list.
"$PY" "$REPO_ROOT/backend/scripts/reconcile_local_postgres_role.py" --env-file "$ENV_FILE"
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='ai_email'" | grep -q 1; then
sudo -u postgres createdb ai_email
fi
sudo -u postgres psql -d ai_email -v ON_ERROR_STOP=1 \
-c "CREATE EXTENSION IF NOT EXISTS vector;" >/dev/null

echo "==> [start] applying database migrations (alembic upgrade head)"
cd "$REPO_ROOT/backend"
"$PY" scripts/migrate_db.py

echo "==> [start] done"
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ in this repo.
embedding generation is unavailable. Tests must cover the local
`embeddinggemma` path so Data workspace imports do not silently bypass the
selected embedding model.
- PostgreSQL `text` / `hashtext()` cannot encode a NUL (`0x00`) octet. Do not
pass a raw `user_id\\x00organization_id` (or any other NUL-separated payload)
as a `pg_advisory_lock` bind value. Derive a NUL-free digest such as
SHA-256 hex and keep mocked/SQLite tests from asserting the raw NUL form —
those dialects skip the lock and hide `CharacterNotInRepertoireError` until
a real Postgres import.
- Home/Today dashboard reply-wait surfaces must read signed
`/api/emails/pending-replies` data instead of inferring pending replies from
generic inbox fixtures or static copy. Tests and E2E mocks must verify the
Expand Down
6 changes: 6 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ remains a local/dev compatibility path that creates the `vector` extension,
metadata-defined tables for fresh local databases, and idempotent backfills for
existing local databases.

Cloud Agent VMs use the same managed migration path through
`.cursor/start.sh` → `scripts/migrate_db.py`. Local role-secret alignment is
stdin dollar-quoted SQL (`scripts/reconcile_local_postgres_role.py`); do not
interpolate `DATABASE_URL` secrets into `psql -c`. See
[`docs/development/cloud-agent-environment.md`](docs/development/cloud-agent-environment.md).

## Send boundary

Outbound replies preserve `In-Reply-To` and `References` headers in the built
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## [Unreleased]
- Cloud Agent `.cursor/start.sh`는 `DATABASE_URL` 역할 비밀을 `psql -c`에 보간하지 않고 `scripts/reconcile_local_postgres_role.py`의 dollar-quoted stdin으로 맞춥니다. `install.sh`는 `requirements-hashes.txt`를 `--require-hashes`로 설치합니다. 신선한 DB에서 폐기된 `emails` 테이블을 가정하던 `0011_email_read_state`는 테이블이 없으면 no-op이고, `0019_email_record_read_state`가 `0018_email_send_rate_buckets` 다음에 정식 `email_records.is_read DEFAULT true`를 가드로 맞춥니다. 가져오기 quota advisory lock 키는 NUL 없는 SHA-256 hex입니다.
- 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.

Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ The other compose files are purpose-specific evaluation/evidence stacks:
defaults anywhere; Compose/Kubernetes/operators must inject them, and startup
fails closed when they are missing.

### Cloud Agent environment (`.cursor/`)

Repo-managed Cloud Agent bootstrap lives in `.cursor/environment.json`.
`install.sh` installs PostgreSQL 16/pgvector and hashed Python deps;
`start.sh` brings Postgres online, mints a per-VM `~/.env`, and runs
`scripts/migrate_db.py`. Role-secret sync goes through
`backend/scripts/reconcile_local_postgres_role.py` (dollar-quoted stdin, never
`psql -c` interpolation). After boot, open http://127.0.0.1:3000 and sign in
with a minted HMAC session — see
`docs/development/cloud-agent-environment.md`.

## Architecture

Naruon is an AI email workspace: a web client/control plane over
Expand Down
18 changes: 17 additions & 1 deletion backend/alembic/versions/0011_email_read_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@


def upgrade() -> None:
# ``emails`` was the pre-reconciliation email table. ``email_records`` is now
# the single source of truth and already declares ``is_read`` via the model
# metadata created in 0001, so this legacy column add only applies to older
# databases that still carry the retired ``emails`` table. Guard on the table
# existing (matching the has_table/has_column pattern used by later
# revisions) so ``alembic upgrade head`` succeeds on fresh databases.
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("emails"):
return
existing_columns = {column["name"] for column in inspector.get_columns("emails")}
if "is_read" in existing_columns:
return
op.add_column(
"emails",
sa.Column(
Expand All @@ -26,4 +39,7 @@ def upgrade() -> None:


def downgrade() -> None:
op.drop_column("emails", "is_read")
# No-op: this revision only reconciles a retired ``emails`` table. Dropping
# ``is_read`` when the column is present would also remove a pre-existing
# column this revision did not create.
return
44 changes: 44 additions & 0 deletions backend/alembic/versions/0018_email_send_rate_buckets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""add shared email send rate-limit buckets

Revision ID: 0018_email_send_rate_buckets
Revises: 0017_merge_newsdom_carddav_heads
Create Date: 2026-08-19 00:00:00.000000
"""

from alembic import op
import sqlalchemy as sa

revision = "0018_email_send_rate_buckets"
down_revision = "0017_merge_newsdom_carddav_heads"
branch_labels = None
depends_on = None

_BUCKET_TABLE = "email_send_rate_buckets"
_EXPIRY_INDEX = "ix_email_send_rate_buckets_expires_at"


def upgrade() -> None:
connection = op.get_bind()
inspector = sa.inspect(connection)
if not inspector.has_table(_BUCKET_TABLE):
op.create_table(
_BUCKET_TABLE,
sa.Column("bucket_scope_hash", sa.String(length=64), nullable=False),
sa.Column("window_started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("attempt_count", sa.Integer(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("bucket_scope_hash"),
)
op.create_index(
_EXPIRY_INDEX,
_BUCKET_TABLE,
["expires_at"],
if_not_exists=True,
)


def downgrade() -> None:
op.drop_index(_EXPIRY_INDEX, table_name=_BUCKET_TABLE, if_exists=True)
op.drop_table(_BUCKET_TABLE, if_exists=True)
62 changes: 62 additions & 0 deletions backend/alembic/versions/0019_email_record_read_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Guard email_records.is_read with NOT NULL DEFAULT true.

Revision ID: 0019_email_record_read_state
Revises: 0018_email_send_rate_buckets

``0011_email_read_state`` only mutates the retired ``emails`` table. Fresh
databases already receive ``email_records.is_read`` from ``0001``
``create_all`` plus the current model ``server_default``. Existing databases
whose ``email_records`` row predates that column (or lacks a server default)
still need a guarded additive revision so raw INSERTs that omit ``is_read``
succeed.
"""

from __future__ import annotations

from alembic import op
import sqlalchemy as sa

revision = "0019_email_record_read_state"
down_revision = "0018_email_send_rate_buckets"
branch_labels = None
depends_on = None

_EMAIL_RECORDS_TABLE = "email_records"
_READ_STATE_COLUMN = "is_read"


def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table(_EMAIL_RECORDS_TABLE):
return
columns = {
column["name"]: column
for column in inspector.get_columns(_EMAIL_RECORDS_TABLE)
}
if _READ_STATE_COLUMN not in columns:
op.add_column(
_EMAIL_RECORDS_TABLE,
sa.Column(
_READ_STATE_COLUMN,
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
return
if columns[_READ_STATE_COLUMN].get("default") is None:
op.alter_column(
_EMAIL_RECORDS_TABLE,
_READ_STATE_COLUMN,
existing_type=sa.Boolean(),
existing_nullable=False,
server_default=sa.text("true"),
)


def downgrade() -> None:
# No-op: ``create_all`` and later model metadata may already own this
# column. Dropping it would remove a default this revision did not
# necessarily create.
return
Loading
Loading