diff --git a/.env.example b/.env.example index 0b75406bc..6f4bdb747 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=change-me-local-only DATABASE_URL=postgresql+asyncpg://postgres:change-me-local-only@localhost:5432/ai_email DEBUG=false +AUTH_MODE=hybrid +TRUST_DEV_HEADERS=false +LEGACY_EMAIL_OWNER_USER_ID= ENCRYPTION_KEY= # AI features. Leave blank to disable LLM/embedding-backed flows locally. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 891df14d9..bb465134e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,6 +14,15 @@ The backend owns persistence, threading, search, AI summaries, and outbound send orchestration. The frontend consumes the backend contracts and renders inbox, detail, thread history, reply composer, and network graph surfaces. +The current frontend shell is organized around three operator-facing work +surfaces: inbox triage, detail/conversation review, and a context/judgment/ +execution rail. Desktop now exposes board-style summary cards above the main +workspace, while mobile uses a five-item bottom navigation (`받은 메일`, `맥락 +종합`, `판단 포인트`, `실행 항목`, `설정`) and swipe gestures to push email +threads into a persisted per-user `ExecutionItem` queue. The frontend still +keeps a user-scoped local cache for rendering/subscription, but queue creation +and completion now flow through `/api/execution-items`. + ## Threading boundary `backend/services/threading_service.py` is the canonical domain service for @@ -23,10 +32,42 @@ documented in `docs/threading-contract.md`. ## Data and tenancy boundary -The current `emails` table does not have an owner/mailbox key. Email and -search behavior should therefore be treated as single-user local-development -behavior. Multi-user production safety requires a schema migration that adds -mailbox ownership and applies that filter to every email/search query. +The current `emails` table now has a minimal owner field (`Email.user_id`) and +the inbox/detail/thread read paths already filter on it. That closes the most +obvious single-user leakage, but it is still only a bridge. Multi-user +production safety still requires a real `Mailbox` aggregate with provider/source +identity plus mailbox/member/org scoped filtering for email, search, network, +attachments, and synthetic routing. + +`MailboxAccount` is now the first mailbox-configuration aggregate: multiple +accounts per user, one default reply account, and explicit SMTP/IMAP credentials +separate from the legacy `TenantConfig` singleton. Compose/send flows can now +target a linked mailbox account, but stored emails still need a future +`mailbox_account_id` rollout across every real sync/import path before reply +routing becomes fully automatic for all stored messages. Fixture/ZIP imports now +attempt that routing, and the IMAP worker now enumerates active `MailboxAccount` +rows instead of the legacy singleton config, but real persisted IMAP/POP/OAuth +sync paths are still pending. The inbox +API and UI are now mailbox-aware as well: list items carry `mailbox_account_id`, +the inbox can filter by a linked mailbox account, and rows show their source +account label. Search now follows that same scope and keeps mailbox provenance in +its result items so the UI does not lose account context when a user searches. +During the bridge period, mailbox-filtered thread and search paths include +legacy `mailbox_account_id IS NULL` rows for the authenticated owner so restored +thread context is not lost before the mailbox migration is complete; the inbox UI +labels those bridge rows as `이전 복원 메일` when a mailbox filter is active. +Bootstrap now fails closed if ownerless legacy email rows remain and +`LEGACY_EMAIL_OWNER_USER_ID` is not set, preventing a silent deployment where +existing inbox/search/network data disappears behind the new owner filters. + +`MailboxAccount` also now carries POP3 credentials and the background worker +enumerates active POP3 accounts from that aggregate with bounded socket timeouts, +and the app lifecycle now starts/stops that worker beside IMAP. Mailbox-account +create/update also normalizes mailbox strings, blocks internal-network mail +server hosts and non-mail ports, enforces server/port pair invariants, forces +default-reply accounts active, and returns stable 409/503 errors for duplicate +account or missing encryption-key failures. This is still only a +connection/scheduling foundation, not a persisted message ingestion path. ## Local deployment boundary @@ -72,12 +113,47 @@ SMTP/IMAP providers as documented in the current repo and physical replication/WAL restore remain future work per `docs/operations/postgresql-physical-replication.md`. -Authentication still uses trusted request headers in local/dev, but the backend -now normalizes them into an auth context with `platform_admin`, -`organization_admin`, `group_admin`, and `member` roles plus optional -organization/group scope. This preserves the current `X-User-Id` development -flow while preparing the backend for future Keycloak/Casdoor claims and -organization-scoped resources that should not assume one workspace per user; see -`docs/operations/auth-key-management.md`. The current Kubernetes ingress assumes -NGINX, while Traefik is only an evaluated option in +Authentication is bearer/OIDC-derived at the backend boundary. `hybrid` remains +the fail-closed default and accepts verified bearer claims; `oidc` requires a +verified bearer token; the legacy `header` mode no longer authenticates +client-controlled identity headers. `DEBUG` and `TRUST_DEV_HEADERS` do not make +`X-User-*` request headers authoritative for user, role, organization, group, or +workspace scope. Local developer and live-E2E flows must use signed bearer tokens +or an OIDC provider/session. + +In `oidc`/`hybrid`, bearer tokens are normalized into the shared +`AuthContext` with `platform_admin`, `organization_admin`, `group_admin`, and +`member` roles plus optional organization/group scope. The backend currently +accepts HS256 shared-secret tokens and RS256 bearer tokens validated against a +configured JWKS URL, which is enough for staged Keycloak/Casdoor integration. + +The frontend no longer emits development identity headers and the legacy dev +identity switcher renders nothing. Admin settings and Prompt Studio controls are +claim-gated in the UI before the backend performs its own authorization checks. +Runner tokens are organization-scoped, while LLM provider access is now +org-scoped through `organization_id`. Shared prompt templates require both +organization scope and a workspace-admin role (`platform_admin` or +`organization_admin`), while user-owned private prompts remain visible to their +creator. Provider-backed prompt testing is also workspace-admin-only because it +uses the organization's configured LLM provider. Legacy provider rows require an +explicit bootstrap migration mapping (`LEGACY_LLM_PROVIDER_ORGANIZATION_ID`) +before they reappear in the new org scoped APIs. See +`docs/operations/auth-key-management.md`. The current +Kubernetes ingress assumes NGINX, while Traefik is only an evaluated option in `docs/operations/traefik-evaluation.md`. + +`ExecutionItem` is now a persisted personal queue fed from email swipe/actions. +It is intentionally self-owned (`user_id`) and currently uses a temporary +mailbox ownership check through `Email.user_id`, and list/detail/thread reads in +`/api/emails` now use that same owner field. This is the first real object-scope +boundary, but it is still only a per-user ownership column, not yet a full +`Mailbox` aggregate with account/provider/source provenance. + +Execution items now also preserve source mailbox context and an excerpt from the +source email (`source_mailbox_account_id`, `source_snippet`) so the action board +can keep account provenance visible after the email leaves the inbox view. + +Calendar sync is authenticated but intentionally has no body-token trust path: +`/api/calendar/sync` ignores any submitted `user_token` and returns 503 until a +server-side per-user calendar credential store is implemented. That keeps the UI +action honest without turning app identity claims into Google OAuth credentials. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2762cf1ae..819bb4bb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [Unreleased] + +### 보안 +- Seongho Bae (@seonghobae): backend 인증 경로에서 client-controlled `X-User-*` 개발 헤더 신뢰를 제거하고, 로컬/테스트 흐름을 서명된 bearer/OIDC claims 기반으로 전환했습니다. + ## [0.14.1] - 2026-05-13 ### 수정 diff --git a/README.md b/README.md index a2947717a..2b1369942 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Errors should tell a contributor what failed and avoid leaking internals: ## Current scope contract -This repo still uses trusted header auth in local/dev and does not persist an owner/mailbox key on email rows. The backend now derives an auth context with platform/organization/group/member scopes, but email data should still be treated as single-user development data until a mailbox ownership migration is added. +This repo now derives backend auth context from verified bearer/OIDC claims, not trusted local/dev identity headers. Email rows have a bridge owner field, but email data should still be treated as pre-production multi-user data until the full mailbox ownership migration lands. ## Operations and release docs @@ -84,7 +84,7 @@ This repo still uses trusted header auth in local/dev and does not persist an ow - `docs/operations/open-source-apm.md`: OpenTelemetry, Prometheus, Grafana, Loki, Tempo/Jaeger adoption plan. - `docs/operations/email-relay-proxy-boundary.md`: Naruon is a web client relay/proxy, not an email server. - `docs/operations/postgresql-physical-replication.md`: physical replication, WAL, restore, and read-routing plan. -- `docs/operations/auth-key-management.md`: dummy auth boundary, Fernet key management, Keycloak/Casdoor evaluation. +- `docs/operations/auth-key-management.md`: bearer/OIDC auth boundary, Fernet key management, Keycloak/Casdoor evaluation. - `docs/operations/traefik-evaluation.md`: Traefik versus current NGINX ingress evaluation. ## Verification used for this hardening pass diff --git a/backend/api/auth.py b/backend/api/auth.py index 30cded986..8360b3fbe 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -1,11 +1,14 @@ +import asyncio from dataclasses import dataclass +from functools import lru_cache from typing import Literal, cast, get_args +import jwt from fastapi import Depends, Header, HTTPException +from pydantic import SecretStr from core.config import settings - RoleName = Literal["platform_admin", "organization_admin", "group_admin", "member"] SCOPED_ROLES: set[str] = set(get_args(RoleName)) @@ -26,24 +29,138 @@ def _normalize_header_value(value: object) -> str | None: return normalized or None -def _derive_role(user_id: str, requested_role: str | None) -> RoleName: - """ - Derive user role from request headers in trusted dev/test mode. - - In production, header-provided roles are ignored and every request is treated - as `member` until SSO/OIDC claims are wired in. - """ - if not (settings.DEBUG or settings.TRUST_DEV_HEADERS): - return "member" - if requested_role in SCOPED_ROLES: +def _extract_role_from_claims(user_id: str, claims: dict) -> RoleName: + requested_role = claims.get("naruon_role") or claims.get("role") + if isinstance(requested_role, str) and requested_role in SCOPED_ROLES: return cast(RoleName, requested_role) - return "organization_admin" if user_id == "admin" else "member" + + roles = claims.get("roles") + if isinstance(roles, list): + for candidate in ( + "platform_admin", + "organization_admin", + "group_admin", + "member", + ): + if candidate in roles: + return cast(RoleName, candidate) + + return "member" + + +def _build_context_from_claims(claims: dict) -> AuthContext: + user_id = claims.get("sub") + if not isinstance(user_id, str) or not user_id.strip(): + raise HTTPException(status_code=401, detail="Authentication required") + + organization_id = claims.get("organization_id") or claims.get("org_id") + if organization_id is not None and not isinstance(organization_id, str): + organization_id = None + + raw_groups = claims.get("groups") + group_ids: tuple[str, ...] + if isinstance(raw_groups, list): + group_ids = tuple( + str(group).strip() for group in raw_groups if str(group).strip() + ) + else: + group_ids = () + + role = _extract_role_from_claims(user_id, claims) + workspace_id = _derive_workspace_id(user_id, organization_id) + return AuthContext( + user_id=user_id, + role=role, + organization_id=organization_id, + group_ids=group_ids, + workspace_id=workspace_id, + ) -def _parse_group_ids(group_ids_header: str | None) -> tuple[str, ...]: - if not group_ids_header: - return () - return tuple(group_id.strip() for group_id in group_ids_header.split(",") if group_id.strip()) +@lru_cache(maxsize=8) +def _get_jwk_client(jwks_url: str) -> jwt.PyJWKClient: + return jwt.PyJWKClient(jwks_url) + + +def _require_oidc_claim_expectations() -> tuple[str, str]: + raw_issuer = getattr(settings, "OIDC_ISSUER", None) + raw_audience = getattr(settings, "OIDC_AUDIENCE", None) + issuer = raw_issuer.strip() if raw_issuer else None + audience = raw_audience.strip() if raw_audience else None + if not issuer or not audience: + raise HTTPException( + status_code=503, detail="OIDC issuer and audience are not configured" + ) + return issuer, audience + + +def _get_oidc_shared_secret() -> str | None: + secret = getattr(settings, "OIDC_SHARED_SECRET", None) + if isinstance(secret, SecretStr): + return secret.get_secret_value() + return secret + + +def _expected_oidc_algorithm() -> Literal["RS256", "HS256"]: + if getattr(settings, "OIDC_JWKS_URL", None): + return "RS256" + if _get_oidc_shared_secret(): + return "HS256" + raise HTTPException(status_code=503, detail="OIDC verifier is not configured") + + +async def _decode_bearer_token(authorization: str) -> dict: + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Invalid bearer token") + + token = authorization.split(" ", 1)[1].strip() + if not token: + raise HTTPException(status_code=401, detail="Invalid bearer token") + + issuer, audience = _require_oidc_claim_expectations() + options = {"require": ["sub", "iss", "aud", "exp"]} + try: + header = jwt.get_unverified_header(token) + algorithm = header.get("alg") + expected_algorithm = _expected_oidc_algorithm() + if algorithm != expected_algorithm: + raise HTTPException(status_code=401, detail="Invalid bearer token") + if expected_algorithm == "RS256": + jwks_url = settings.OIDC_JWKS_URL + if not jwks_url: + raise HTTPException( + status_code=503, detail="OIDC JWKS URL is not configured" + ) + signing_key = await asyncio.to_thread( + _get_jwk_client(jwks_url).get_signing_key_from_jwt, token + ) + return jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=audience, + issuer=issuer, + options=options, + ) + + secret = _get_oidc_shared_secret() + if not secret: + raise HTTPException( + status_code=503, detail="OIDC shared secret is not configured" + ) + + return jwt.decode( + token, + secret, + algorithms=["HS256"], + audience=audience, + issuer=issuer, + options=options, + ) + except HTTPException: + raise + except (jwt.PyJWTError, jwt.PyJWKClientError) as exc: + raise HTTPException(status_code=401, detail="Invalid bearer token") from exc def _derive_workspace_id( @@ -65,69 +182,36 @@ def ensure_organization_access(auth_context: AuthContext, organization_id: str) if auth_context.role == "platform_admin": return if auth_context.organization_id != organization_id: - raise HTTPException(status_code=403, detail="Resource belongs to a different organization") + raise HTTPException( + status_code=403, detail="Resource belongs to a different organization" + ) async def get_auth_context( - x_user_id: str | None = Header(None, alias="X-User-Id"), - x_user_role: str | None = Header(None, alias="X-User-Role"), - x_organization_id: str | None = Header(None, alias="X-Organization-Id"), - x_group_ids: str | None = Header(None, alias="X-Group-Ids"), + authorization: str | None = Header(None, alias="Authorization"), ) -> AuthContext: - return build_auth_context( - x_user_id=x_user_id, - x_user_role=x_user_role, - x_organization_id=x_organization_id, - x_group_ids=x_group_ids, - ) - + authorization_value = _normalize_header_value(authorization) + if authorization_value and settings.AUTH_MODE in {"oidc", "hybrid"}: + return _build_context_from_claims( + await _decode_bearer_token(authorization_value) + ) -def build_auth_context( - x_user_id: object, - x_user_role: object = None, - x_organization_id: object = None, - x_group_ids: object = None, -) -> AuthContext: - - """ - Builds an auth context from the current request headers. - Today this still trusts local/dev headers, but the shape matches future - token-derived scope claims from Keycloak or Casdoor. In production, - role headers are ignored and requests remain `member` until OIDC is wired. - """ - user_id = _normalize_header_value(x_user_id) - if user_id is None: - raise HTTPException(status_code=401, detail="Authentication required") - - organization_id = _normalize_header_value(x_organization_id) - role = _derive_role(user_id, _normalize_header_value(x_user_role)) - group_ids = _parse_group_ids(_normalize_header_value(x_group_ids)) - workspace_id = _derive_workspace_id(user_id, organization_id) - - return AuthContext( - user_id=user_id, - role=role, - organization_id=organization_id, - group_ids=group_ids, - workspace_id=workspace_id, - ) + raise HTTPException(status_code=401, detail="Authentication required") async def get_current_user( - x_user_id: str | None = Header(None, alias="X-User-Id"), - x_user_role: str | None = Header(None, alias="X-User-Role"), - x_organization_id: str | None = Header(None, alias="X-Organization-Id"), - x_group_ids: str | None = Header(None, alias="X-Group-Ids"), + authorization: str | None = Header(None, alias="Authorization"), ) -> str: - return build_auth_context( - x_user_id=x_user_id, - x_user_role=x_user_role, - x_organization_id=x_organization_id, - x_group_ids=x_group_ids, + return ( + await get_auth_context( + authorization=authorization, + ) ).user_id -async def get_current_workspace_id(auth_context: AuthContext = Depends(get_auth_context)) -> str: +async def get_current_workspace_id( + auth_context: AuthContext = Depends(get_auth_context), +) -> str: return auth_context.workspace_id diff --git a/backend/api/calendar.py b/backend/api/calendar.py index 1ef94389d..7e35c66cb 100644 --- a/backend/api/calendar.py +++ b/backend/api/calendar.py @@ -1,5 +1,6 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel +from api.auth import AuthContext, get_auth_context from services.calendar_service import create_calendar_event from services.exceptions import CalendarServiceError @@ -8,15 +9,34 @@ class SyncRequest(BaseModel): todos: list[str] - user_token: dict + user_token: dict | None = None + + +def get_calendar_credentials_for_user(auth_context: AuthContext) -> dict | None: + """Return server-side calendar credentials for the authenticated user. + + Body-provided tokens are intentionally ignored. A future calendar account + aggregate should load encrypted OAuth credentials here; until then the route + fails closed instead of turning the caller identity into Google credentials. + """ + + return None @router.post("/sync") -async def sync_todos(request: SyncRequest): +async def sync_todos( + request: SyncRequest, auth_context: AuthContext = Depends(get_auth_context) +): try: results = [] + calendar_credentials = get_calendar_credentials_for_user(auth_context) + if not calendar_credentials: + raise HTTPException( + status_code=503, + detail="Calendar credentials are not configured for this user", + ) for todo in request.todos: - event = await create_calendar_event(todo, request.user_token) + event = await create_calendar_event(todo, calendar_credentials) results.append(event) return {"synced": len(results), "events": results} except CalendarServiceError as e: diff --git a/backend/api/emails.py b/backend/api/emails.py index 2cc827cc2..0e6595f8e 100644 --- a/backend/api/emails.py +++ b/backend/api/emails.py @@ -2,13 +2,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import or_, select from db.session import get_db -from db.models import Email, TenantConfig +from db.models import Email, MailboxAccount, TenantConfig from pydantic import BaseModel, EmailStr import datetime +import html from services.email_client import send_email +from services.email_parser import sanitize_email_html_to_text from services.threading_service import normalize_message_id import logging from api.auth import get_current_user +from api.mailbox_scope import require_owned_mailbox_account logger = logging.getLogger(__name__) @@ -28,8 +31,16 @@ def thread_lookup_values(thread_id: str) -> list[str]: return list({thread_id, normalized, f"<{normalized}>"}) +def sanitize_email_body_for_response(body: str) -> str: + decoded_body = html.unescape(body) + if "<" not in decoded_body or ">" not in decoded_body: + return body + return sanitize_email_html_to_text(decoded_body) + + class EmailListItem(BaseModel): id: int + mailbox_account_id: int | None = None thread_id: str | None = None subject: str | None sender: str @@ -38,8 +49,10 @@ class EmailListItem(BaseModel): snippet: str reply_count: int | None = None + class EmailDetailResponse(BaseModel): id: int + mailbox_account_id: int | None = None message_id: str thread_id: str | None = None sender: str @@ -55,12 +68,18 @@ class EmailDetailResponse(BaseModel): @router.get("", response_model=dict[str, list[EmailListItem]]) async def get_emails( limit: int = Query(default=50, ge=1, le=200), + mailbox_account_id: int | None = Query(default=None, ge=1), db: AsyncSession = Depends(get_db), current_user: str = Depends(get_current_user), ): + await require_owned_mailbox_account(db, current_user, mailbox_account_id) + candidate_window = min(max(limit * 10, 200), 2000) + statement = select(Email).where(Email.user_id == current_user) + if mailbox_account_id is not None: + statement = statement.where(Email.mailbox_account_id == mailbox_account_id) result = await db.execute( - select(Email).order_by(Email.date.desc()).limit(candidate_window) + statement.order_by(Email.date.desc()).limit(candidate_window) ) emails = result.scalars().all() emails = sorted(emails, key=lambda item: item.date) @@ -82,10 +101,12 @@ async def get_emails( items = [] for email in sorted_groups: group_key = canonical_thread_key(email) - snippet = email.body[:100] + "..." if len(email.body) > 100 else email.body + safe_body = sanitize_email_body_for_response(email.body) + snippet = safe_body[:100] + "..." if len(safe_body) > 100 else safe_body items.append( EmailListItem( id=email.id, + mailbox_account_id=email.mailbox_account_id, subject=email.subject, sender=email.sender, reply_to=email.reply_to, @@ -104,37 +125,52 @@ async def get_email( db: AsyncSession = Depends(get_db), current_user: str = Depends(get_current_user), ): - result = await db.execute(select(Email).where(Email.id == email_id)) + result = await db.execute( + select(Email).where(Email.id == email_id, Email.user_id == current_user) + ) email = result.scalar_one_or_none() if not email: raise HTTPException(status_code=404, detail="Email not found") return EmailDetailResponse( id=email.id, + mailbox_account_id=email.mailbox_account_id, message_id=email.message_id, sender=email.sender, reply_to=email.reply_to, recipients=email.recipients, subject=email.subject, date=email.date, - body=email.body, + body=sanitize_email_body_for_response(email.body), thread_id=canonical_thread_key(email), in_reply_to=email.in_reply_to, references=email.references, ) -@router.get("/thread/{thread_id:path}", response_model=dict[str, list[EmailDetailResponse]]) +@router.get( + "/thread/{thread_id:path}", response_model=dict[str, list[EmailDetailResponse]] +) async def get_email_thread( thread_id: str, + mailbox_account_id: int | None = Query(default=None, ge=1), db: AsyncSession = Depends(get_db), current_user: str = Depends(get_current_user), ): + await require_owned_mailbox_account(db, current_user, mailbox_account_id) + lookup_values = thread_lookup_values(thread_id) - result = await db.execute( - select(Email) - .where(or_(Email.thread_id.in_(lookup_values), Email.message_id.in_(lookup_values))) - .order_by(Email.date.asc()) + statement = select(Email).where( + Email.user_id == current_user, + or_(Email.thread_id.in_(lookup_values), Email.message_id.in_(lookup_values)), ) + if mailbox_account_id is not None: + statement = statement.where( + or_( + Email.mailbox_account_id == mailbox_account_id, + Email.mailbox_account_id.is_(None), + ) + ) + result = await db.execute(statement.order_by(Email.date.asc())) emails = result.scalars().all() emails = sorted(emails, key=lambda item: item.date) if not emails: @@ -145,13 +181,14 @@ async def get_email_thread( items.append( EmailDetailResponse( id=email.id, + mailbox_account_id=email.mailbox_account_id, message_id=email.message_id, sender=email.sender, reply_to=email.reply_to, recipients=email.recipients, subject=email.subject, date=email.date, - body=email.body, + body=sanitize_email_body_for_response(email.body), thread_id=canonical_thread_key(email), in_reply_to=email.in_reply_to, references=email.references, @@ -164,40 +201,95 @@ class SendEmailRequest(BaseModel): to: EmailStr subject: str body: str - in_reply_to: str | None = None # O3: email threading support + mailbox_account_id: int | None = None + in_reply_to: str | None = None # O3: email threading support references: str | None = None @router.post("/send") async def send_email_endpoint( - request: SendEmailRequest, user_id: str | None = None, db: AsyncSession = Depends(get_db), current_user: str = Depends(get_current_user) + request: SendEmailRequest, + user_id: str | None = None, + db: AsyncSession = Depends(get_db), + current_user: str = Depends(get_current_user), ): if user_id and user_id != current_user: raise HTTPException(status_code=403, detail="Not authorized") target_user_id = user_id or current_user try: - tenant_config = await db.scalar(select(TenantConfig).where(TenantConfig.user_id == target_user_id)) - - if not tenant_config or not tenant_config.smtp_server or not tenant_config.smtp_port or not tenant_config.smtp_username: - raise HTTPException(status_code=400, detail="SMTP is not configured") - - try: - smtp_server = tenant_config.smtp_server - smtp_port = tenant_config.smtp_port - smtp_username = tenant_config.smtp_username - smtp_password = tenant_config.smtp_password - except Exception as exc: - if "ENCRYPTION_KEY is required" in str(exc): - raise HTTPException( - status_code=503, - detail="Server encryption key is not configured. Contact your workspace administrator.", - ) from exc - raise - + mailbox_account = None + if request.mailbox_account_id is not None: + mailbox_account = await db.scalar( + select(MailboxAccount).where( + MailboxAccount.id == request.mailbox_account_id, + MailboxAccount.user_id == target_user_id, + ) + ) + if not mailbox_account: + raise HTTPException(status_code=404, detail="Mailbox account not found") + else: + mailbox_account = await db.scalar( + select(MailboxAccount).where( + MailboxAccount.user_id == target_user_id, + MailboxAccount.is_default_reply.is_(True), + MailboxAccount.is_active.is_(True), + ) + ) + + if ( + mailbox_account + and mailbox_account.smtp_server + and mailbox_account.smtp_port + and mailbox_account.smtp_username + ): + try: + smtp_server = mailbox_account.smtp_server + smtp_port = mailbox_account.smtp_port + smtp_username = mailbox_account.smtp_username + smtp_password = mailbox_account.smtp_password + except Exception as exc: + if "ENCRYPTION_KEY is required" in str(exc): + raise HTTPException( + status_code=503, + detail=( + "Server encryption key is not configured. " + "Contact your workspace administrator." + ), + ) from exc + raise + else: + tenant_config = await db.scalar( + select(TenantConfig).where(TenantConfig.user_id == target_user_id) + ) + + if ( + not tenant_config + or not tenant_config.smtp_server + or not tenant_config.smtp_port + or not tenant_config.smtp_username + ): + raise HTTPException(status_code=400, detail="SMTP is not configured") + + try: + smtp_server = tenant_config.smtp_server + smtp_port = tenant_config.smtp_port + smtp_username = tenant_config.smtp_username + smtp_password = tenant_config.smtp_password + except Exception as exc: + if "ENCRYPTION_KEY is required" in str(exc): + raise HTTPException( + status_code=503, + detail=( + "Server encryption key is not configured. " + "Contact your workspace administrator." + ), + ) from exc + raise + send_result = await send_email( - request.to, - request.subject, + request.to, + request.subject, request.body, smtp_server=smtp_server, smtp_port=smtp_port, diff --git a/backend/api/execution_items.py b/backend/api/execution_items.py new file mode 100644 index 000000000..424b6e596 --- /dev/null +++ b/backend/api/execution_items.py @@ -0,0 +1,166 @@ +import datetime +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from api.auth import AuthContext, get_auth_context +from db.models import Email, ExecutionItem +from db.session import get_db + +router = APIRouter(prefix="/api/execution-items") + + +class ExecutionItemResponse(BaseModel): + id: int + user_id: str + organization_id: str | None = None + workspace_id: str + source_mailbox_account_id: int | None = None + source_email_id: int | None = None + source_thread_id: str | None = None + source_message_id: str | None = None + source_snippet: str | None = None + title: str + sender: str + status: str + created_at: datetime.datetime + updated_at: datetime.datetime + completed_at: datetime.datetime | None = None + + model_config = ConfigDict(from_attributes=True) + + +class ExecutionItemListResponse(BaseModel): + items: list[ExecutionItemResponse] + + +class QueueExecutionItemRequest(BaseModel): + email_id: int + + +class UpdateExecutionItemRequest(BaseModel): + status: Literal["queued", "done"] + + +def _snapshot_email_fields(item: ExecutionItem, email: Email) -> None: + item.source_mailbox_account_id = email.mailbox_account_id + item.source_email_id = email.id + item.source_thread_id = email.thread_id + item.source_message_id = email.message_id + item.source_snippet = email.body[:200] + ("..." if len(email.body) > 200 else "") + item.title = email.subject or "(제목 없음)" + item.sender = email.sender + + +@router.get("", response_model=ExecutionItemListResponse) +async def list_execution_items( + db: AsyncSession = Depends(get_db), + auth_context: AuthContext = Depends(get_auth_context), +): + result = await db.execute( + select(ExecutionItem).where( + ExecutionItem.user_id == auth_context.user_id, + ExecutionItem.workspace_id == auth_context.workspace_id, + ) + ) + items = sorted( + result.scalars().all(), key=lambda item: item.updated_at, reverse=True + ) + return {"items": items} + + +@router.post("/from-email") +async def queue_execution_item_from_email( + request: QueueExecutionItemRequest, + db: AsyncSession = Depends(get_db), + auth_context: AuthContext = Depends(get_auth_context), +): + email_result = await db.execute( + select(Email).where( + Email.id == request.email_id, Email.user_id == auth_context.user_id + ) + ) + email = email_result.scalar_one_or_none() + if not email: + raise HTTPException(status_code=404, detail="Source email not found") + + result = await db.execute( + select(ExecutionItem).where( + ExecutionItem.user_id == auth_context.user_id, + ExecutionItem.workspace_id == auth_context.workspace_id, + ExecutionItem.source_email_id == request.email_id, + ) + ) + item = result.scalar_one_or_none() + now = datetime.datetime.now(datetime.timezone.utc) + + if item: + _snapshot_email_fields(item, email) + item.status = "queued" + item.completed_at = None + item.updated_at = now + else: + item = ExecutionItem( + user_id=auth_context.user_id, + organization_id=auth_context.organization_id, + workspace_id=auth_context.workspace_id, + source_mailbox_account_id=email.mailbox_account_id, + source_email_id=email.id, + source_thread_id=email.thread_id, + source_message_id=email.message_id, + source_snippet=email.body[:200] + ("..." if len(email.body) > 200 else ""), + title=email.subject or "(제목 없음)", + sender=email.sender, + status="queued", + created_at=now, + updated_at=now, + ) + db.add(item) + + try: + await db.commit() + except IntegrityError: + await db.rollback() + existing_result = await db.execute( + select(ExecutionItem).where( + ExecutionItem.user_id == auth_context.user_id, + ExecutionItem.workspace_id == auth_context.workspace_id, + ExecutionItem.source_email_id == request.email_id, + ) + ) + item = existing_result.scalar_one_or_none() + if not item: + raise + await db.refresh(item) + return {"item": item} + + +@router.patch("/{item_id}") +async def update_execution_item( + item_id: int, + request: UpdateExecutionItemRequest, + db: AsyncSession = Depends(get_db), + auth_context: AuthContext = Depends(get_auth_context), +): + result = await db.execute( + select(ExecutionItem).where( + ExecutionItem.id == item_id, + ExecutionItem.user_id == auth_context.user_id, + ExecutionItem.workspace_id == auth_context.workspace_id, + ) + ) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Execution item not found") + + item.status = request.status + item.updated_at = datetime.datetime.now(datetime.timezone.utc) + item.completed_at = item.updated_at if request.status == "done" else None + + await db.commit() + await db.refresh(item) + return {"item": item} diff --git a/backend/api/llm.py b/backend/api/llm.py index 99dbcd3b1..c2525f1a5 100644 --- a/backend/api/llm.py +++ b/backend/api/llm.py @@ -4,7 +4,7 @@ from sqlalchemy import select from fastapi import Depends from db.session import get_db -from db.models import TenantConfig, LLMProvider +from db.models import TenantConfig from api.auth import get_current_user from services.llm_service import ( extract_todos_and_summary, @@ -26,16 +26,23 @@ class DraftRequest(BaseModel): @router.post("/summarize", response_model=ExtractionResult) -async def summarize_endpoint(request: SummarizeRequest, user_id: str | None = None, db: AsyncSession = Depends(get_db), current_user: str = Depends(get_current_user)): +async def summarize_endpoint( + request: SummarizeRequest, + user_id: str | None = None, + db: AsyncSession = Depends(get_db), + current_user: str = Depends(get_current_user), +): if user_id and user_id != current_user: raise HTTPException(status_code=403, detail="Not authorized") target_user_id = user_id or current_user try: - tenant_config = await db.scalar(select(TenantConfig).where(TenantConfig.user_id == target_user_id)) + tenant_config = await db.scalar( + select(TenantConfig).where(TenantConfig.user_id == target_user_id) + ) if not tenant_config or not tenant_config.openai_api_key: raise HTTPException(status_code=400, detail="OpenAI API key not configured") - + openai_api_key = tenant_config.openai_api_key return await extract_todos_and_summary(request.email_body, openai_api_key) except LLMServiceError: @@ -51,18 +58,27 @@ async def summarize_endpoint(request: SummarizeRequest, user_id: str | None = No @router.post("/draft") -async def draft_endpoint(request: DraftRequest, user_id: str | None = None, db: AsyncSession = Depends(get_db), current_user: str = Depends(get_current_user)): +async def draft_endpoint( + request: DraftRequest, + user_id: str | None = None, + db: AsyncSession = Depends(get_db), + current_user: str = Depends(get_current_user), +): if user_id and user_id != current_user: raise HTTPException(status_code=403, detail="Not authorized") target_user_id = user_id or current_user try: - tenant_config = await db.scalar(select(TenantConfig).where(TenantConfig.user_id == target_user_id)) + tenant_config = await db.scalar( + select(TenantConfig).where(TenantConfig.user_id == target_user_id) + ) if not tenant_config or not tenant_config.openai_api_key: raise HTTPException(status_code=400, detail="OpenAI API key not configured") - + openai_api_key = tenant_config.openai_api_key - reply = await draft_reply(request.email_body, request.instruction, openai_api_key) + reply = await draft_reply( + request.email_body, request.instruction, openai_api_key + ) return {"draft": reply} except LLMServiceError: raise HTTPException( diff --git a/backend/api/llm_providers.py b/backend/api/llm_providers.py index ca9f7abe4..090bbb135 100644 --- a/backend/api/llm_providers.py +++ b/backend/api/llm_providers.py @@ -1,16 +1,19 @@ +from sqlalchemy import select +import datetime +from typing import List, Optional + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import List, Optional -import datetime + from db.session import get_db -from db.models import LLMProvider, AuditLog from api.auth import AuthContext, get_auth_context +from db.models import AuditLog, LLMProvider from pydantic import BaseModel router = APIRouter(prefix="/api/llm-providers", tags=["llm-providers"]) + class LLMProviderCreate(BaseModel): name: str provider_type: str @@ -18,6 +21,7 @@ class LLMProviderCreate(BaseModel): api_key: Optional[str] = None is_active: bool = False + class LLMProviderUpdate(BaseModel): name: Optional[str] = None provider_type: Optional[str] = None @@ -25,6 +29,7 @@ class LLMProviderUpdate(BaseModel): api_key: Optional[str] = None is_active: Optional[bool] = None + class LLMProviderResponse(BaseModel): id: int name: str @@ -45,52 +50,68 @@ def _get_fingerprint(api_key: str | None) -> str | None: return f"***{api_key[-4:]}" return "***" -async def check_admin_access(auth_context: AuthContext = Depends(get_auth_context)): + +async def require_provider_admin( + auth_context: AuthContext = Depends(get_auth_context), +) -> AuthContext: if auth_context.role not in {"platform_admin", "organization_admin"}: - raise HTTPException(status_code=403, detail="Organization admin access required") - return auth_context.user_id + raise HTTPException( + status_code=403, detail="Organization admin access required" + ) + if not auth_context.organization_id: + raise HTTPException(status_code=403, detail="Organization scope is required") + return auth_context + @router.get("", response_model=List[LLMProviderResponse]) async def list_providers( db: AsyncSession = Depends(get_db), - auth_context: AuthContext = Depends(get_auth_context) + auth_context: AuthContext = Depends(require_provider_admin), ): - result = await db.execute(select(LLMProvider)) + result = await db.execute( + select(LLMProvider).where( + LLMProvider.organization_id == auth_context.organization_id + ) + ) providers = result.scalars().all() - + responses = [] for p in providers: - responses.append(LLMProviderResponse( - id=p.id, - name=p.name, - provider_type=p.provider_type, - base_url=p.base_url, - is_active=p.is_active, - configured=bool(p.api_key), - fingerprint=_get_fingerprint(p.api_key), - updated_at=p.updated_at - )) + responses.append( + LLMProviderResponse( + id=p.id, + name=p.name, + provider_type=p.provider_type, + base_url=p.base_url, + is_active=p.is_active, + configured=bool(p.api_key), + fingerprint=_get_fingerprint(p.api_key), + updated_at=p.updated_at, + ) + ) return responses + @router.post("", response_model=LLMProviderResponse) async def create_provider( data: LLMProviderCreate, db: AsyncSession = Depends(get_db), - user_id: str = Depends(check_admin_access) + auth_context: AuthContext = Depends(require_provider_admin), ): provider = LLMProvider( name=data.name, provider_type=data.provider_type, base_url=data.base_url, api_key=data.api_key, - is_active=data.is_active + is_active=data.is_active, + organization_id=auth_context.organization_id, ) db.add(provider) audit = AuditLog( - user_id=user_id, + user_id=auth_context.user_id, action="create", resource_type="llm_provider", - details=f"Created provider {data.name}" + details=f"Created provider {data.name}", ) db.add(audit) try: @@ -99,7 +120,7 @@ async def create_provider( except IntegrityError: await db.rollback() raise HTTPException(status_code=409, detail="Provider name already exists") - + return LLMProviderResponse( id=provider.id, name=provider.name, @@ -108,21 +129,27 @@ async def create_provider( is_active=provider.is_active, configured=bool(provider.api_key), fingerprint=_get_fingerprint(provider.api_key), - updated_at=provider.updated_at + updated_at=provider.updated_at, ) + @router.put("/{provider_id}", response_model=LLMProviderResponse) async def update_provider( provider_id: int, data: LLMProviderUpdate, db: AsyncSession = Depends(get_db), - user_id: str = Depends(check_admin_access) + auth_context: AuthContext = Depends(require_provider_admin), ): - result = await db.execute(select(LLMProvider).where(LLMProvider.id == provider_id)) + result = await db.execute( + select(LLMProvider).where( + LLMProvider.id == provider_id, + LLMProvider.organization_id == auth_context.organization_id, + ) + ) provider = result.scalars().first() if not provider: raise HTTPException(status_code=404, detail="Provider not found") - + updated = False if data.name is not None: provider.name = data.name @@ -139,19 +166,23 @@ async def update_provider( if data.is_active is not None: provider.is_active = data.is_active updated = True - + if updated: audit = AuditLog( - user_id=user_id, + user_id=auth_context.user_id, action="update", resource_type="llm_provider", resource_id=str(provider.id), - details=f"Updated provider {provider.name}" + details=f"Updated provider {provider.name}", ) db.add(audit) - await db.commit() - await db.refresh(provider) - + try: + await db.commit() + await db.refresh(provider) + except IntegrityError: + await db.rollback() + raise HTTPException(status_code=409, detail="Provider name already exists") + return LLMProviderResponse( id=provider.id, name=provider.name, @@ -160,27 +191,33 @@ async def update_provider( is_active=provider.is_active, configured=bool(provider.api_key), fingerprint=_get_fingerprint(provider.api_key), - updated_at=provider.updated_at + updated_at=provider.updated_at, ) + @router.delete("/{provider_id}", status_code=204) async def delete_provider( provider_id: int, db: AsyncSession = Depends(get_db), - user_id: str = Depends(check_admin_access) + auth_context: AuthContext = Depends(require_provider_admin), ): - result = await db.execute(select(LLMProvider).where(LLMProvider.id == provider_id)) + result = await db.execute( + select(LLMProvider).where( + LLMProvider.id == provider_id, + LLMProvider.organization_id == auth_context.organization_id, + ) + ) provider = result.scalars().first() if not provider: raise HTTPException(status_code=404, detail="Provider not found") - + await db.delete(provider) audit = AuditLog( - user_id=user_id, + user_id=auth_context.user_id, action="delete", resource_type="llm_provider", resource_id=str(provider.id), - details=f"Deleted provider {provider.name}" + details=f"Deleted provider {provider.name}", ) db.add(audit) await db.commit() diff --git a/backend/api/mailbox_accounts.py b/backend/api/mailbox_accounts.py new file mode 100644 index 000000000..398bc32b8 --- /dev/null +++ b/backend/api/mailbox_accounts.py @@ -0,0 +1,282 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from api.auth import get_current_user +from db.models import MailboxAccount +from db.session import get_db +from services.email_parser import _sanitize_nul +from services.mail_server_security import ( + MailServerValidationError, + validate_mail_server_host, +) + +router = APIRouter(prefix="/api/mailbox-accounts") + +SECRET_FIELDS = {"smtp_password", "imap_password", "pop3_password"} + + +class MailboxAccountCreate(BaseModel): + email_address: str + display_name: str | None = None + provider: str = "custom" + is_default_reply: bool = False + is_active: bool = True + smtp_server: str | None = None + smtp_port: int | None = None + smtp_username: str | None = None + smtp_password: str | None = None + imap_server: str | None = None + imap_port: int | None = None + imap_username: str | None = None + imap_password: str | None = None + pop3_server: str | None = None + pop3_port: int | None = None + pop3_username: str | None = None + pop3_password: str | None = None + + +class MailboxAccountUpdate(BaseModel): + email_address: str | None = None + display_name: str | None = None + provider: str | None = None + is_default_reply: bool | None = None + is_active: bool | None = None + smtp_server: str | None = None + smtp_port: int | None = None + smtp_username: str | None = None + smtp_password: str | None = None + imap_server: str | None = None + imap_port: int | None = None + imap_username: str | None = None + imap_password: str | None = None + pop3_server: str | None = None + pop3_port: int | None = None + pop3_username: str | None = None + pop3_password: str | None = None + + +class MailboxAccountResponse(BaseModel): + id: int + user_id: str + email_address: str + display_name: str | None = None + provider: str + is_default_reply: bool + is_active: bool + smtp_server: str | None = None + smtp_port: int | None = None + smtp_username: str | None = None + smtp_password_set: bool + imap_server: str | None = None + imap_port: int | None = None + imap_username: str | None = None + imap_password_set: bool + pop3_server: str | None = None + pop3_port: int | None = None + pop3_username: str | None = None + pop3_password_set: bool + + model_config = ConfigDict(from_attributes=True) + + +class MailboxAccountListResponse(BaseModel): + items: list[MailboxAccountResponse] + + +def _sanitize_mailbox_payload(payload: dict[str, object]) -> dict[str, object]: + sanitized: dict[str, object] = {} + for key, value in payload.items(): + if isinstance(value, str): + normalized = _sanitize_nul(value) + if key in { + "email_address", + "provider", + "smtp_server", + "smtp_username", + "imap_server", + "imap_username", + "pop3_server", + "pop3_username", + }: + normalized = normalized.strip() + if key == "display_name": + normalized = normalized.strip() + sanitized[key] = normalized + else: + sanitized[key] = value + return sanitized + + +def _validate_mailbox_payload(payload: dict[str, object]) -> dict[str, object]: + email_address = payload.get("email_address") + if isinstance(email_address, str) and not email_address: + raise HTTPException( + status_code=400, detail="메일 주소는 비어 있을 수 없습니다." + ) + + for prefix, label in (("smtp", "SMTP"), ("imap", "IMAP"), ("pop3", "POP3")): + server = payload.get(f"{prefix}_server") + port = payload.get(f"{prefix}_port") + has_server = isinstance(server, str) and bool(server) + has_port = port is not None + if has_server != has_port: + raise HTTPException( + status_code=400, detail=f"{label} 서버와 포트는 함께 설정해야 합니다." + ) + if has_server and isinstance(server, str) and isinstance(port, int): + try: + payload[f"{prefix}_server"] = validate_mail_server_host( + prefix, + label, + server, + port, + ) + except MailServerValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if payload.get("is_default_reply") is True: + payload["is_active"] = True + return payload + + +def _serialize_account(account: MailboxAccount) -> MailboxAccountResponse: + return MailboxAccountResponse( + id=account.id, + user_id=account.user_id, + email_address=account.email_address, + display_name=account.display_name, + provider=account.provider, + is_default_reply=account.is_default_reply, + is_active=account.is_active, + smtp_server=account.smtp_server, + smtp_port=account.smtp_port, + smtp_username=account.smtp_username, + smtp_password_set=bool(account.smtp_password), + imap_server=account.imap_server, + imap_port=account.imap_port, + imap_username=account.imap_username, + imap_password_set=bool(account.imap_password), + pop3_server=account.pop3_server, + pop3_port=account.pop3_port, + pop3_username=account.pop3_username, + pop3_password_set=bool(account.pop3_password), + ) + + +async def _get_account_or_404( + db: AsyncSession, current_user: str, account_id: int +) -> MailboxAccount: + result = await db.execute( + select(MailboxAccount).where( + MailboxAccount.id == account_id, MailboxAccount.user_id == current_user + ) + ) + account = result.scalar_one_or_none() + if not account: + raise HTTPException(status_code=404, detail="Mailbox account not found") + return account + + +async def _clear_default_reply(db: AsyncSession, current_user: str) -> None: + result = await db.execute( + select(MailboxAccount).where(MailboxAccount.user_id == current_user) + ) + for account in result.scalars().all(): + account.is_default_reply = False + + +@router.get("", response_model=MailboxAccountListResponse) +async def list_mailbox_accounts( + db: AsyncSession = Depends(get_db), + current_user: str = Depends(get_current_user), +): + result = await db.execute( + select(MailboxAccount).where(MailboxAccount.user_id == current_user) + ) + accounts = sorted( + result.scalars().all(), key=lambda item: item.updated_at, reverse=True + ) + return {"items": [_serialize_account(account) for account in accounts]} + + +@router.post("") +async def create_mailbox_account( + payload: MailboxAccountCreate, + db: AsyncSession = Depends(get_db), + current_user: str = Depends(get_current_user), +): + if payload.is_default_reply: + await _clear_default_reply(db, current_user) + + account_payload = _validate_mailbox_payload( + _sanitize_mailbox_payload(payload.model_dump()) + ) + account = MailboxAccount(user_id=current_user, **account_payload) + db.add(account) + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise HTTPException(status_code=409, detail="Mailbox account already exists") + except Exception as exc: + await db.rollback() + if "ENCRYPTION_KEY is required" not in str(exc): + raise + raise HTTPException( + status_code=503, + detail="Server encryption key is not configured. Contact your workspace administrator.", + ) from exc + await db.refresh(account) + return {"item": _serialize_account(account)} + + +@router.patch("/{account_id}") +async def update_mailbox_account( + account_id: int, + payload: MailboxAccountUpdate, + db: AsyncSession = Depends(get_db), + current_user: str = Depends(get_current_user), +): + account = await _get_account_or_404(db, current_user, account_id) + update_data = _validate_mailbox_payload( + _sanitize_mailbox_payload(payload.model_dump(exclude_unset=True)) + ) + if update_data.get("is_default_reply"): + await _clear_default_reply(db, current_user) + for key, value in update_data.items(): + if key in SECRET_FIELDS and value == "********": + continue + setattr(account, key, value) + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise HTTPException(status_code=409, detail="Mailbox account already exists") + except Exception as exc: + await db.rollback() + if "ENCRYPTION_KEY is required" not in str(exc): + raise + raise HTTPException( + status_code=503, + detail="Server encryption key is not configured. Contact your workspace administrator.", + ) from exc + await db.refresh(account) + return {"item": _serialize_account(account)} + + +@router.post("/{account_id}/make-default-reply") +async def make_default_reply( + account_id: int, + db: AsyncSession = Depends(get_db), + current_user: str = Depends(get_current_user), +): + account = await _get_account_or_404(db, current_user, account_id) + await _clear_default_reply(db, current_user) + account.is_default_reply = True + account.is_active = True + await db.commit() + await db.refresh(account) + return {"item": _serialize_account(account)} diff --git a/backend/api/mailbox_scope.py b/backend/api/mailbox_scope.py new file mode 100644 index 000000000..956037161 --- /dev/null +++ b/backend/api/mailbox_scope.py @@ -0,0 +1,21 @@ +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models import MailboxAccount + + +async def require_owned_mailbox_account( + db: AsyncSession, current_user: str, mailbox_account_id: int | None +) -> None: + if mailbox_account_id is None: + return + + account_id = await db.scalar( + select(MailboxAccount.id).where( + MailboxAccount.id == mailbox_account_id, + MailboxAccount.user_id == current_user, + ) + ) + if account_id is None: + raise HTTPException(status_code=404, detail="Mailbox account not found") diff --git a/backend/api/network.py b/backend/api/network.py index 8c115ee4b..cedd61d88 100644 --- a/backend/api/network.py +++ b/backend/api/network.py @@ -36,14 +36,20 @@ def extract_emails(text: str | None) -> list[str]: @router.get("/graph", response_model=GraphResponse) async def get_network_graph( - limit: int = 500, user_id: str | None = None, db: AsyncSession = Depends(get_db), current_user: str = Depends(get_current_user) + limit: int = 500, + user_id: str | None = None, + db: AsyncSession = Depends(get_db), + current_user: str = Depends(get_current_user), ): if user_id and user_id != current_user: raise HTTPException(status_code=403, detail="Not authorized") target_user_id = user_id or current_user - # TODO: filter by target_user_id once Email model has user_id - result = await db.execute(select(Email.sender, Email.recipients).limit(limit)) + result = await db.execute( + select(Email.sender, Email.recipients) + .where(Email.user_id == target_user_id) + .limit(limit) + ) rows = result.fetchall() nodes_set = set() diff --git a/backend/api/prompts.py b/backend/api/prompts.py index 93fbcda83..7099a351d 100644 --- a/backend/api/prompts.py +++ b/backend/api/prompts.py @@ -1,21 +1,25 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import and_, or_, select from typing import List, Optional from db.session import get_db -from db.models import PromptTemplate, LLMProvider, TenantConfig -from api.auth import get_current_user +from db.models import PromptTemplate, LLMProvider +from api.auth import AuthContext, get_auth_context from pydantic import BaseModel import datetime router = APIRouter(prefix="/api/prompts", tags=["prompts"]) +WORKSPACE_ADMIN_ROLES = {"platform_admin", "organization_admin"} + + class PromptCreate(BaseModel): title: str description: Optional[str] = None content: str is_shared: bool = False + class PromptResponse(BaseModel): id: int title: str @@ -23,6 +27,7 @@ class PromptResponse(BaseModel): content: str is_shared: bool created_by: str + organization_id: str | None = None created_at: datetime.datetime updated_at: datetime.datetime @@ -33,88 +38,134 @@ class PromptTestRequest(BaseModel): content: str variables: dict[str, str] + class PromptTestResponse(BaseModel): result: str -async def execute_prompt_with_llm(prompt_text: str, api_key: str, base_url: Optional[str] = None) -> dict: + +async def execute_prompt_with_llm( + prompt_text: str, api_key: str, base_url: Optional[str] = None +) -> dict: from openai import AsyncOpenAI from core.config import settings - + client = AsyncOpenAI(api_key=api_key, base_url=base_url) try: response = await client.chat.completions.create( model=settings.OPENAI_MODEL, - messages=[ - {"role": "user", "content": prompt_text} - ], - temperature=0.0 + messages=[{"role": "user", "content": prompt_text}], + temperature=0.0, ) content = response.choices[0].message.content return {"result": content if content else ""} except Exception as e: import logging + logging.getLogger(__name__).error(f"Prompt execution failed: {e}") - raise HTTPException(status_code=502, detail="Failed to execute prompt with AI provider. Check provider status.") + raise HTTPException( + status_code=502, + detail="Failed to execute prompt with AI provider. Check provider status.", + ) + + +def build_prompt_list_statement(auth_context: AuthContext): + owned_prompt = PromptTemplate.created_by == auth_context.user_id + if not auth_context.organization_id: + return select(PromptTemplate).where(owned_prompt) + + shared_in_org = and_( + PromptTemplate.is_shared.is_(True), + PromptTemplate.organization_id == auth_context.organization_id, + ) + return select(PromptTemplate).where(or_(owned_prompt, shared_in_org)) + + +def require_workspace_admin(auth_context: AuthContext, detail: str) -> None: + if auth_context.role not in WORKSPACE_ADMIN_ROLES: + raise HTTPException(status_code=403, detail=detail) + + +def remove_nul_bytes(value: Optional[str]) -> Optional[str]: + if value is None: + return None + return value.replace("\x00", "") + @router.get("", response_model=List[PromptResponse]) async def list_prompts( db: AsyncSession = Depends(get_db), - user_id: str = Depends(get_current_user) + auth_context: AuthContext = Depends(get_auth_context), ): - # Only return prompts created by user OR shared prompts - result = await db.execute( - select(PromptTemplate).where( - (PromptTemplate.created_by == user_id) | (PromptTemplate.is_shared == True) - ) - ) + # Return prompts created by user OR org-shared prompts in the same org only. + result = await db.execute(build_prompt_list_statement(auth_context)) return result.scalars().all() + @router.post("", response_model=PromptResponse) async def create_prompt( data: PromptCreate, db: AsyncSession = Depends(get_db), - user_id: str = Depends(get_current_user) + auth_context: AuthContext = Depends(get_auth_context), ): + if data.is_shared and not auth_context.organization_id: + raise HTTPException( + status_code=400, detail="Shared prompts require organization scope" + ) + if data.is_shared: + require_workspace_admin( + auth_context, + "Workspace admin role is required for shared prompts", + ) + prompt = PromptTemplate( - title=data.title, - description=data.description, - content=data.content, + title=remove_nul_bytes(data.title), + description=remove_nul_bytes(data.description), + content=remove_nul_bytes(data.content), is_shared=data.is_shared, - created_by=user_id + created_by=auth_context.user_id, + organization_id=auth_context.organization_id, ) db.add(prompt) await db.commit() await db.refresh(prompt) return prompt + @router.post("/test", response_model=PromptTestResponse) async def test_prompt( data: PromptTestRequest, db: AsyncSession = Depends(get_db), - user_id: str = Depends(get_current_user) + auth_context: AuthContext = Depends(get_auth_context), ): - # Substitute variables - prompt_text = data.content - for k, v in data.variables.items(): - prompt_text = prompt_text.replace(f"{{{{{k}}}}}", v) - - # Find active provider - provider_result = await db.execute(select(LLMProvider).where(LLMProvider.is_active == True)) + require_workspace_admin( + auth_context, + "Workspace admin role is required for prompt testing", + ) + if not auth_context.organization_id: + raise HTTPException(status_code=400, detail="LLM API key not configured") + + # Find active provider in the authenticated organization only. + provider_result = await db.execute( + select(LLMProvider).where( + LLMProvider.organization_id == auth_context.organization_id, + LLMProvider.is_active.is_(True), + ) + ) active_provider = provider_result.scalars().first() - + api_key = None base_url = None - + if active_provider and active_provider.api_key: api_key = active_provider.api_key base_url = active_provider.base_url - else: - tenant_result = await db.execute(select(TenantConfig).where(TenantConfig.user_id == user_id)) - tenant_config = tenant_result.scalars().first() - if tenant_config and tenant_config.openai_api_key: - api_key = tenant_config.openai_api_key - + if not api_key: raise HTTPException(status_code=400, detail="LLM API key not configured") - + + # Substitute variables only after authorization and provider validation. + prompt_text = remove_nul_bytes(data.content) or "" + for k, v in data.variables.items(): + prompt_text = prompt_text.replace(f"{{{{{k}}}}}", remove_nul_bytes(v) or "") + return await execute_prompt_with_llm(prompt_text, api_key, base_url) diff --git a/backend/api/runtime_config.py b/backend/api/runtime_config.py index 2061447f8..c8abb6bbf 100644 --- a/backend/api/runtime_config.py +++ b/backend/api/runtime_config.py @@ -1,22 +1,34 @@ from fastapi import APIRouter from pydantic import BaseModel +from core.config import settings + router = APIRouter(prefix="/api/runtime-config", tags=["runtime-config"]) + class RuntimeConfigResponse(BaseModel): product_name: str version: str features: dict[str, bool] + @router.get("", response_model=RuntimeConfigResponse) async def get_runtime_config(): # Return basic non-secret configuration + manual_bearer_login_enabled = ( + settings.AUTH_MODE in {"hybrid", "oidc"} + and bool(settings.OIDC_ISSUER) + and bool(settings.OIDC_AUDIENCE) + and bool(settings.OIDC_SHARED_SECRET or settings.OIDC_JWKS_URL) + ) return RuntimeConfigResponse( product_name="Naruon", version="0.5.1", features={ "llm_enabled": True, "smtp_enabled": True, - "imap_enabled": True - } + "imap_enabled": True, + "dev_header_auth_enabled": False, + "manual_bearer_login_enabled": manual_bearer_login_enabled, + }, ) diff --git a/backend/api/search.py b/backend/api/search.py index dc218f43d..18410aa0c 100644 --- a/backend/api/search.py +++ b/backend/api/search.py @@ -3,8 +3,9 @@ import logging from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, union_all +from sqlalchemy import or_, select, func, union_all from db.session import get_db +from api.mailbox_scope import require_owned_mailbox_account from db.models import Email, Attachment, TenantConfig from services.embedding import generate_embeddings from api.auth import get_current_user @@ -16,10 +17,12 @@ class SearchRequest(BaseModel): query: str limit: int = Field(default=10, ge=1, le=50) + mailbox_account_id: int | None = Field(default=None, ge=1) class SearchResultItem(BaseModel): id: int + mailbox_account_id: int | None = None subject: str | None sender: str date: datetime.datetime @@ -34,26 +37,46 @@ class SearchResponse(BaseModel): def thread_group_key(): - normalized_thread_id = func.nullif(func.btrim(func.btrim(Email.thread_id), "<>"), "") - normalized_message_id = func.nullif(func.btrim(func.btrim(Email.message_id), "<>"), "") + normalized_thread_id = func.nullif( + func.btrim(func.btrim(Email.thread_id), "<>"), "" + ) + normalized_message_id = func.nullif( + func.btrim(func.btrim(Email.message_id), "<>"), "" + ) return func.coalesce(normalized_thread_id, normalized_message_id) -def build_reply_counts_subquery(): +def mailbox_scope_predicate(mailbox_account_id: int): + return or_( + Email.mailbox_account_id == mailbox_account_id, + Email.mailbox_account_id.is_(None), + ) + + +def build_reply_counts_subquery( + current_user: str, mailbox_account_id: int | None = None +): group_key = thread_group_key() - return ( + statement = ( select( group_key.label("thread_key"), func.count(Email.id).label("reply_count"), ) .select_from(Email) - .group_by(group_key) - .subquery("thread_counts") + .where(Email.user_id == current_user) ) + if mailbox_account_id is not None: + statement = statement.where(mailbox_scope_predicate(mailbox_account_id)) + return statement.group_by(group_key).subquery("thread_counts") @router.post("/search", response_model=SearchResponse) -async def hybrid_search(request: SearchRequest, user_id: str | None = None, db: AsyncSession = Depends(get_db), current_user: str = Depends(get_current_user)): +async def hybrid_search( + request: SearchRequest, + user_id: str | None = None, + db: AsyncSession = Depends(get_db), + current_user: str = Depends(get_current_user), +): if user_id and user_id != current_user: raise HTTPException(status_code=403, detail="Not authorized") target_user_id = user_id or current_user @@ -61,13 +84,17 @@ async def hybrid_search(request: SearchRequest, user_id: str | None = None, db: if not request.query.strip(): return SearchResponse(results=[]) + await require_owned_mailbox_account(db, current_user, request.mailbox_account_id) + try: - tenant_config = await db.scalar(select(TenantConfig).where(TenantConfig.user_id == target_user_id)) + tenant_config = await db.scalar( + select(TenantConfig).where(TenantConfig.user_id == target_user_id) + ) if not tenant_config or not tenant_config.openai_api_key: raise HTTPException(status_code=400, detail="OpenAI API key not configured") - + openai_api_key = tenant_config.openai_api_key - + embeddings = await generate_embeddings([request.query], openai_api_key) query_embedding = embeddings[0] @@ -77,11 +104,14 @@ async def hybrid_search(request: SearchRequest, user_id: str | None = None, db: ) vector_distance_email = Email.embedding.cosine_distance(query_embedding) hybrid_score_email = fts_score_email - vector_distance_email - reply_counts = build_reply_counts_subquery() + reply_counts = build_reply_counts_subquery( + current_user, request.mailbox_account_id + ) stmt_email = ( select( Email.id, + Email.mailbox_account_id, Email.subject, Email.sender, Email.date, @@ -92,7 +122,12 @@ async def hybrid_search(request: SearchRequest, user_id: str | None = None, db: ) .select_from(Email) .join(reply_counts, reply_counts.c.thread_key == thread_group_key()) + .where(Email.user_id == current_user) ) + if request.mailbox_account_id is not None: + stmt_email = stmt_email.where( + mailbox_scope_predicate(request.mailbox_account_id) + ) fts_score_att = func.ts_rank_cd( func.to_tsvector("english", Attachment.content), @@ -104,6 +139,7 @@ async def hybrid_search(request: SearchRequest, user_id: str | None = None, db: stmt_att = ( select( Email.id, + Email.mailbox_account_id, Email.subject, Email.sender, Email.date, @@ -115,13 +151,19 @@ async def hybrid_search(request: SearchRequest, user_id: str | None = None, db: .select_from(Attachment) .join(Email, Attachment.email_id == Email.id) .join(reply_counts, reply_counts.c.thread_key == thread_group_key()) + .where(Email.user_id == current_user) ) + if request.mailbox_account_id is not None: + stmt_att = stmt_att.where( + mailbox_scope_predicate(request.mailbox_account_id) + ) combined = union_all(stmt_email, stmt_att).cte("combined_search") stmt = ( select( combined.c.id, + combined.c.mailbox_account_id, combined.c.subject, combined.c.sender, combined.c.date, @@ -155,6 +197,7 @@ async def hybrid_search(request: SearchRequest, user_id: str | None = None, db: search_results.append( SearchResultItem( id=row.id, + mailbox_account_id=row.mailbox_account_id, subject=row.subject, sender=row.sender, date=row.date, diff --git a/backend/core/config.py b/backend/core/config.py index 9aadbc4c8..e888037db 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -1,3 +1,5 @@ +from typing import Literal + from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict @@ -7,6 +9,13 @@ class Settings(BaseSettings): DEBUG: bool = False TRUST_DEV_HEADERS: bool = False ENCRYPTION_KEY: SecretStr | None = None + AUTH_MODE: Literal["header", "hybrid", "oidc"] = "hybrid" + OIDC_ISSUER: str | None = None + OIDC_AUDIENCE: str | None = None + OIDC_JWKS_URL: str | None = None + OIDC_SHARED_SECRET: SecretStr | str | None = None + LEGACY_LLM_PROVIDER_ORGANIZATION_ID: str | None = None + LEGACY_EMAIL_OWNER_USER_ID: str | None = None # OpenAI Settings OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-small" diff --git a/backend/db/models.py b/backend/db/models.py index 6b601eb68..65f452cd6 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -1,5 +1,5 @@ from sqlalchemy.orm import declarative_base, Mapped, mapped_column, relationship -from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean +from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean, UniqueConstraint from sqlalchemy.types import TypeDecorator from cryptography.fernet import Fernet from core.config import settings @@ -13,8 +13,6 @@ Base = declarative_base() -FALLBACK_KEY = b"f_Z_GZzHjJ-mO2hP5k-yJ-W0t-J9_YlB-H_V-_m-_A0=" - def get_fernet() -> Fernet: if settings.ENCRYPTION_KEY: @@ -24,15 +22,13 @@ def get_fernet() -> Fernet: except ValueError: key_b64 = base64.urlsafe_b64encode(hashlib.sha256(key).digest()) return Fernet(key_b64) - else: - if not settings.DEBUG: - raise RuntimeError("ENCRYPTION_KEY is required in production. Refusing to use fallback key.") - return Fernet(FALLBACK_KEY) + raise RuntimeError("ENCRYPTION_KEY is required to encrypt secrets") class EncryptedString(TypeDecorator): """ - Encrypts string values before saving to the database and decrypts them when retrieving. + Encrypts string values before saving to the database and decrypts them when + retrieving. Uses Fernet symmetric encryption. """ @@ -60,7 +56,9 @@ class AuditLog(Base): __tablename__ = "audit_logs" id: Mapped[int] = mapped_column(primary_key=True) - timestamp: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), default=datetime.datetime.utcnow) + timestamp: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), default=datetime.datetime.utcnow + ) user_id: Mapped[str] = mapped_column(String, index=True) action: Mapped[str] = mapped_column(String) resource_type: Mapped[str] = mapped_column(String) @@ -70,14 +68,28 @@ class AuditLog(Base): class LLMProvider(Base): __tablename__ = "llm_providers" + __table_args__ = ( + UniqueConstraint( + "organization_id", "name", name="uq_llm_providers_organization_name" + ), + ) id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String, index=True, unique=True) - provider_type: Mapped[str] = mapped_column(String) # e.g. openai, anthropic, gemini, ollama + organization_id: Mapped[str | None] = mapped_column( + String, nullable=True, index=True + ) + name: Mapped[str] = mapped_column(String, index=True) + provider_type: Mapped[str] = mapped_column( + String + ) # e.g. openai, anthropic, gemini, ollama base_url: Mapped[str | None] = mapped_column(String, nullable=True) api_key: Mapped[str | None] = mapped_column(EncryptedString, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=False) - updated_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.datetime.utcnow, + onupdate=datetime.datetime.utcnow, + ) class WorkspaceRunnerConfig(Base): @@ -86,9 +98,13 @@ class WorkspaceRunnerConfig(Base): id: Mapped[int] = mapped_column(primary_key=True) organization_id: Mapped[str] = mapped_column(String, unique=True, index=True) workspace_id: Mapped[str] = mapped_column(String, unique=True, index=True) - registration_token: Mapped[str | None] = mapped_column(EncryptedString, nullable=True) + registration_token: Mapped[str | None] = mapped_column( + EncryptedString, nullable=True + ) updated_at: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + DateTime(timezone=True), + default=datetime.datetime.utcnow, + onupdate=datetime.datetime.utcnow, ) @@ -98,19 +114,27 @@ class Organization(Base): id: Mapped[str] = mapped_column(String, primary_key=True) name: Mapped[str | None] = mapped_column(String, nullable=True) - groups: Mapped[list["OrganizationGroup"]] = relationship(back_populates="organization") - role_assignments: Mapped[list["ScopedRoleAssignment"]] = relationship(back_populates="organization") + groups: Mapped[list["OrganizationGroup"]] = relationship( + back_populates="organization" + ) + role_assignments: Mapped[list["ScopedRoleAssignment"]] = relationship( + back_populates="organization" + ) class OrganizationGroup(Base): __tablename__ = "organization_groups" id: Mapped[str] = mapped_column(String, primary_key=True) - organization_id: Mapped[str] = mapped_column(ForeignKey("organizations.id"), index=True) + organization_id: Mapped[str] = mapped_column( + ForeignKey("organizations.id"), index=True + ) name: Mapped[str | None] = mapped_column(String, nullable=True) organization: Mapped["Organization"] = relationship(back_populates="groups") - role_assignments: Mapped[list["ScopedRoleAssignment"]] = relationship(back_populates="group") + role_assignments: Mapped[list["ScopedRoleAssignment"]] = relationship( + back_populates="group" + ) class ScopedRoleAssignment(Base): @@ -119,11 +143,19 @@ class ScopedRoleAssignment(Base): id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[str] = mapped_column(String, index=True) role: Mapped[str] = mapped_column(String, index=True) - organization_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True) - group_id: Mapped[str | None] = mapped_column(ForeignKey("organization_groups.id"), nullable=True, index=True) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("organizations.id"), nullable=True, index=True + ) + group_id: Mapped[str | None] = mapped_column( + ForeignKey("organization_groups.id"), nullable=True, index=True + ) - organization: Mapped["Organization | None"] = relationship(back_populates="role_assignments") - group: Mapped["OrganizationGroup | None"] = relationship(back_populates="role_assignments") + organization: Mapped["Organization | None"] = relationship( + back_populates="role_assignments" + ) + group: Mapped["OrganizationGroup | None"] = relationship( + back_populates="role_assignments" + ) class PromptTemplate(Base): @@ -135,15 +167,40 @@ class PromptTemplate(Base): content: Mapped[str] = mapped_column(Text) is_shared: Mapped[bool] = mapped_column(Boolean, default=False) created_by: Mapped[str] = mapped_column(String, index=True) - created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.datetime.now(datetime.timezone.utc)) - updated_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.datetime.now(datetime.timezone.utc), onupdate=lambda: datetime.datetime.now(datetime.timezone.utc)) + organization_id: Mapped[str | None] = mapped_column( + String, nullable=True, index=True + ) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + ) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + onupdate=lambda: datetime.datetime.now(datetime.timezone.utc), + ) + class Email(Base): __tablename__ = "emails" + __table_args__ = ( + UniqueConstraint( + "user_id", + "mailbox_account_id", + "message_id", + name="uq_emails_owner_mailbox_message", + ), + ) id: Mapped[int] = mapped_column(primary_key=True) - message_id: Mapped[str] = mapped_column(String, unique=True, index=True) - thread_id: Mapped[str | None] = mapped_column(String, index=True, nullable=True) # O3: email threading support + user_id: Mapped[str | None] = mapped_column(String, index=True, nullable=True) + mailbox_account_id: Mapped[int | None] = mapped_column( + ForeignKey("mailbox_accounts.id"), index=True, nullable=True + ) + message_id: Mapped[str] = mapped_column(String, index=True) + thread_id: Mapped[str | None] = mapped_column( + String, index=True, nullable=True + ) # O3: email threading support sender: Mapped[str] = mapped_column(String) reply_to: Mapped[str | None] = mapped_column(String, nullable=True) recipients: Mapped[str | None] = mapped_column(String, nullable=True) @@ -211,3 +268,99 @@ def __repr__(self) -> str: f"has_openai_key={self.openai_api_key is not None}, " f"has_google_secret={self.google_client_secret is not None})>" ) + + +class MailboxAccount(Base): + __tablename__ = "mailbox_accounts" + __table_args__ = ( + UniqueConstraint( + "user_id", "email_address", name="uq_mailbox_accounts_user_email" + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[str] = mapped_column(String, index=True) + email_address: Mapped[str] = mapped_column(String, index=True) + display_name: Mapped[str | None] = mapped_column(String, nullable=True) + provider: Mapped[str] = mapped_column(String, default="custom") + is_default_reply: Mapped[bool] = mapped_column(Boolean, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + smtp_server: Mapped[str | None] = mapped_column(String, nullable=True) + smtp_port: Mapped[int | None] = mapped_column(nullable=True) + smtp_username: Mapped[str | None] = mapped_column(String, nullable=True) + smtp_password: Mapped[str | None] = mapped_column(EncryptedString, nullable=True) + imap_server: Mapped[str | None] = mapped_column(String, nullable=True) + imap_port: Mapped[int | None] = mapped_column(nullable=True) + imap_username: Mapped[str | None] = mapped_column(String, nullable=True) + imap_password: Mapped[str | None] = mapped_column(EncryptedString, nullable=True) + pop3_server: Mapped[str | None] = mapped_column(String, nullable=True) + pop3_port: Mapped[int | None] = mapped_column(nullable=True) + pop3_username: Mapped[str | None] = mapped_column(String, nullable=True) + pop3_password: Mapped[str | None] = mapped_column(EncryptedString, nullable=True) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + ) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + onupdate=lambda: datetime.datetime.now(datetime.timezone.utc), + ) + + def __repr__(self) -> str: + password_flags = ( + f"has_smtp_password={self.smtp_password is not None}, " + f"has_imap_password={self.imap_password is not None}, " + f"has_pop3_password={self.pop3_password is not None}" + ) + return ( + f"" + ) + + +class ExecutionItem(Base): + __tablename__ = "execution_items" + __table_args__ = ( + UniqueConstraint( + "user_id", + "workspace_id", + "source_email_id", + name="uq_execution_items_user_workspace_email", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[str] = mapped_column(String, index=True) + organization_id: Mapped[str | None] = mapped_column( + String, nullable=True, index=True + ) + workspace_id: Mapped[str] = mapped_column(String, index=True) + source_mailbox_account_id: Mapped[int | None] = mapped_column( + nullable=True, index=True + ) + source_email_id: Mapped[int | None] = mapped_column(nullable=True, index=True) + source_thread_id: Mapped[str | None] = mapped_column( + String, nullable=True, index=True + ) + source_message_id: Mapped[str | None] = mapped_column( + String, nullable=True, index=True + ) + source_snippet: Mapped[str | None] = mapped_column(Text, nullable=True) + title: Mapped[str] = mapped_column(String) + sender: Mapped[str] = mapped_column(String) + status: Mapped[str] = mapped_column(String, default="queued", index=True) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + ) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + onupdate=lambda: datetime.datetime.now(datetime.timezone.utc), + ) + completed_at: Mapped[datetime.datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/backend/db/session.py b/backend/db/session.py index c783a95ad..e7e8f0d34 100644 --- a/backend/db/session.py +++ b/backend/db/session.py @@ -1,4 +1,4 @@ -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from core.config import settings engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG) diff --git a/backend/import_fixtures.py b/backend/import_fixtures.py index e65176687..7f0a9f0ee 100644 --- a/backend/import_fixtures.py +++ b/backend/import_fixtures.py @@ -3,9 +3,11 @@ from pathlib import Path from db.session import AsyncSessionLocal -from db.models import Email, Attachment +from db.models import Email, Attachment, MailboxAccount +from core.config import settings from services.email_parser import parse_eml from services.embedding import generate_embeddings +from services.mailbox_routing import resolve_mailbox_account_id_for_email from services.threading_service import assign_thread_id import os from sqlalchemy import select @@ -22,19 +24,17 @@ async def generate_fixture_embedding(text: str) -> list[float]: async def import_eml_file(session, eml_file: Path) -> bool: + owner_user_id = settings.LEGACY_EMAIL_OWNER_USER_ID + if not owner_user_id: + print("LEGACY_EMAIL_OWNER_USER_ID is required for importing email fixtures.") + return False + try: parsed = parse_eml(eml_file) except Exception as e: print(f"Failed to parse {eml_file}: {e}") return False - existing = await session.execute( - select(Email).where(Email.message_id == parsed["message_id"]) - ) - if existing.scalar_one_or_none(): - print(f"Email {parsed['message_id']} already exists, skipping.") - return False - body_text = parsed["body"] if parsed["body"].strip() else "Empty body" try: body_emb = await generate_fixture_embedding(body_text) @@ -43,8 +43,64 @@ async def import_eml_file(session, eml_file: Path) -> bool: return False thread_id = await assign_thread_id(session, parsed) + mailbox_accounts_result = await session.execute( + select(MailboxAccount).where( + MailboxAccount.user_id == owner_user_id, MailboxAccount.is_active.is_(True) + ) + ) + mailbox_account_id = resolve_mailbox_account_id_for_email( + mailbox_accounts_result.scalars().all(), + sender=parsed["sender"], + reply_to=parsed.get("reply_to"), + recipients=parsed["recipients"], + ) + existing = await session.execute( + select(Email).where( + Email.user_id == owner_user_id, + Email.mailbox_account_id == mailbox_account_id, + Email.message_id == parsed["message_id"], + ) + ) + existing_email = existing.scalar_one_or_none() + if existing_email: + print(f"Email {parsed['message_id']} already exists, skipping.") + return False + + if mailbox_account_id is not None: + legacy_existing_result = await session.execute( + select(Email).where( + Email.user_id == owner_user_id, + Email.mailbox_account_id.is_(None), + Email.message_id == parsed["message_id"], + ) + ) + legacy_existing = legacy_existing_result.scalar_one_or_none() + if legacy_existing: + legacy_existing.mailbox_account_id = mailbox_account_id + legacy_existing.sender = parsed["sender"] + legacy_existing.reply_to = parsed.get("reply_to") + legacy_existing.recipients = parsed["recipients"] + legacy_existing.subject = parsed["subject"] + legacy_existing.in_reply_to = parsed.get("in_reply_to") + legacy_existing.references = parsed.get("references") + legacy_existing.date = parsed["date"] + legacy_existing.body = parsed["body"] + legacy_existing.embedding = body_emb + legacy_existing.thread_id = thread_id + try: + await session.commit() + except Exception as e: + await session.rollback() + print(f"Failed to commit {eml_file}: {e}") + return False + print( + f"Upgraded legacy email {parsed['message_id']} to mailbox account {mailbox_account_id}." + ) + return True email_obj = Email( + user_id=owner_user_id, + mailbox_account_id=mailbox_account_id, message_id=parsed["message_id"], sender=parsed["sender"], reply_to=parsed.get("reply_to"), @@ -79,7 +135,9 @@ async def import_eml_file(session, eml_file: Path) -> bool: await session.rollback() print(f"Failed to commit {eml_file}: {e}") return False - print(f"Imported {eml_file.name} with {len(parsed.get('attachments', []))} attachments.") + print( + f"Imported {eml_file.name} with {len(parsed.get('attachments', []))} attachments." + ) return True diff --git a/backend/main.py b/backend/main.py index 6285cc771..47ccac7de 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,23 +12,38 @@ from api.runtime_config import router as runtime_config_router from api.llm_providers import router as llm_providers_router from api.prompts import router as prompts_router +from api.execution_items import router as execution_items_router +from api.mailbox_accounts import router as mailbox_accounts_router from services.imap_worker import ImapSyncWorker +from services.pop3_worker import Pop3SyncWorker from prometheus_fastapi_instrumentator import Instrumentator from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor -from core.config import settings imap_worker = ImapSyncWorker() +pop3_worker = Pop3SyncWorker() DISABLE_WORKERS = os.environ.get("DISABLE_BACKGROUND_WORKERS") == "1" @asynccontextmanager async def lifespan(app: FastAPI): - if not DISABLE_WORKERS: + if DISABLE_WORKERS: + yield + return + + imap_started = False + pop3_started = False + try: await imap_worker.start() - yield - if not DISABLE_WORKERS: - await imap_worker.stop() + imap_started = True + await pop3_worker.start() + pop3_started = True + yield + finally: + if pop3_started: + await pop3_worker.stop() + if imap_started: + await imap_worker.stop() app = FastAPI( @@ -44,11 +59,9 @@ async def lifespan(app: FastAPI): from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource, SERVICE_NAME - - resource = Resource(attributes={ - SERVICE_NAME: "naruon-backend" - }) - + + resource = Resource(attributes={SERVICE_NAME: "naruon-backend"}) + trace_provider = TracerProvider(resource=resource) processor = BatchSpanProcessor(OTLPSpanExporter()) trace_provider.add_span_processor(processor) @@ -62,7 +75,12 @@ async def lifespan(app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:8000", "http://127.0.0.1:8000"], + allow_origins=[ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:8000", + "http://127.0.0.1:8000", + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -78,6 +96,8 @@ async def lifespan(app: FastAPI): app.include_router(runtime_config_router) app.include_router(llm_providers_router) app.include_router(prompts_router) +app.include_router(execution_items_router) +app.include_router(mailbox_accounts_router) app.add_middleware( diff --git a/backend/requirements.txt b/backend/requirements.txt index 745c55663..3edc72119 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -17,6 +17,7 @@ google-auth-httplib2==0.2.0 google-auth-oauthlib==1.2.0 email-validator==2.1.0 cryptography==47.0.0 +PyJWT==2.10.1 python-multipart==0.0.27 prometheus-fastapi-instrumentator==7.0.0 opentelemetry-api==1.25.0 diff --git a/backend/scripts/bootstrap_db.py b/backend/scripts/bootstrap_db.py index 12f70edf5..6f94b4eb4 100644 --- a/backend/scripts/bootstrap_db.py +++ b/backend/scripts/bootstrap_db.py @@ -2,25 +2,106 @@ from sqlalchemy import text +from core.config import settings from db.models import Base from db.session import engine -def schema_backfill_sql(): - return [ +def schema_backfill_sql( + legacy_llm_provider_organization_id: str | None = None, + legacy_email_owner_user_id: str | None = None, +): + statements = [ + text("ALTER TABLE emails ADD COLUMN IF NOT EXISTS user_id varchar"), + text("ALTER TABLE emails ADD COLUMN IF NOT EXISTS mailbox_account_id integer"), + text("ALTER TABLE emails DROP CONSTRAINT IF EXISTS emails_message_id_key"), text("ALTER TABLE emails ADD COLUMN IF NOT EXISTS thread_id varchar"), text("ALTER TABLE emails ADD COLUMN IF NOT EXISTS in_reply_to varchar"), text('ALTER TABLE emails ADD COLUMN IF NOT EXISTS "references" varchar'), text("ALTER TABLE emails ADD COLUMN IF NOT EXISTS reply_to varchar"), + text("CREATE INDEX IF NOT EXISTS ix_emails_user_id ON emails (user_id)"), + text( + "CREATE INDEX IF NOT EXISTS ix_emails_mailbox_account_id ON emails (mailbox_account_id)" + ), + text("DROP INDEX IF EXISTS ix_emails_message_id"), + text("CREATE INDEX IF NOT EXISTS ix_emails_message_id ON emails (message_id)"), + text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_emails_owner_message_when_mailbox_null ON emails (user_id, message_id) WHERE mailbox_account_id IS NULL" + ), + text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_emails_owner_mailbox_message ON emails (user_id, mailbox_account_id, message_id) WHERE mailbox_account_id IS NOT NULL" + ), text("CREATE INDEX IF NOT EXISTS ix_emails_thread_id ON emails (thread_id)"), + text( + "ALTER TABLE llm_providers ADD COLUMN IF NOT EXISTS organization_id varchar" + ), + text( + "ALTER TABLE llm_providers DROP CONSTRAINT IF EXISTS llm_providers_name_key" + ), + text( + "CREATE INDEX IF NOT EXISTS ix_llm_providers_organization_id ON llm_providers (organization_id)" + ), + text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_llm_providers_organization_name ON llm_providers (organization_id, name)" + ), + text( + "ALTER TABLE prompt_templates ADD COLUMN IF NOT EXISTS organization_id varchar" + ), + text( + "CREATE INDEX IF NOT EXISTS ix_prompt_templates_organization_id ON prompt_templates (organization_id)" + ), + text( + "ALTER TABLE execution_items ADD COLUMN IF NOT EXISTS source_mailbox_account_id integer" + ), + text( + "ALTER TABLE execution_items ADD COLUMN IF NOT EXISTS source_snippet text" + ), + text( + "CREATE INDEX IF NOT EXISTS ix_execution_items_source_mailbox_account_id ON execution_items (source_mailbox_account_id)" + ), + text( + "ALTER TABLE mailbox_accounts ADD COLUMN IF NOT EXISTS pop3_server varchar" + ), + text("ALTER TABLE mailbox_accounts ADD COLUMN IF NOT EXISTS pop3_port integer"), + text( + "ALTER TABLE mailbox_accounts ADD COLUMN IF NOT EXISTS pop3_username varchar" + ), + text( + "ALTER TABLE mailbox_accounts ADD COLUMN IF NOT EXISTS pop3_password text" + ), ] + if legacy_llm_provider_organization_id: + statements.append( + text( + "UPDATE llm_providers SET organization_id = :organization_id WHERE organization_id IS NULL" + ).bindparams(organization_id=legacy_llm_provider_organization_id) + ) + if legacy_email_owner_user_id: + statements.append( + text( + "UPDATE emails SET user_id = :user_id WHERE user_id IS NULL" + ).bindparams(user_id=legacy_email_owner_user_id) + ) + else: + statements.append(text(""" + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM emails WHERE user_id IS NULL) THEN + RAISE EXCEPTION 'LEGACY_EMAIL_OWNER_USER_ID must be set before enabling owner-scoped email reads with legacy ownerless email rows'; + END IF; + END $$; + """)) + return statements async def bootstrap_db() -> None: async with engine.begin() as conn: await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) await conn.run_sync(Base.metadata.create_all) - for statement in schema_backfill_sql(): + for statement in schema_backfill_sql( + settings.LEGACY_LLM_PROVIDER_ORGANIZATION_ID, + settings.LEGACY_EMAIL_OWNER_USER_ID, + ): await conn.execute(statement) diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index 61075659a..1b7217027 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -13,16 +13,23 @@ from services.archive import extract_backup_async from services.email_parser import parse_eml from services.embedding import chunk_text, generate_embeddings +from services.mailbox_routing import resolve_mailbox_account_id_for_email from services.threading_service import assign_thread_id from db.session import AsyncSessionLocal -from db.models import Email, TenantConfig +from db.models import Email, MailboxAccount, TenantConfig from sqlalchemy import select +from core.config import settings logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) async def process_zip_file(zip_path: str | Path, session: AsyncSession): + owner_user_id = settings.LEGACY_EMAIL_OWNER_USER_ID + if not owner_user_id: + logger.error("LEGACY_EMAIL_OWNER_USER_ID is required for ZIP fixture imports.") + return + with tempfile.TemporaryDirectory() as temp_dir: logger.info(f"Extracting {zip_path}...") extracted_files = await extract_backup_async(zip_path, temp_dir) @@ -41,8 +48,14 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): embedding = None if chunks: try: - tenant_config = await session.scalar(select(TenantConfig).where(TenantConfig.user_id == "default")) - api_key = (tenant_config.openai_api_key if tenant_config and tenant_config.openai_api_key else os.getenv("OPENAI_API_KEY")) or "" + tenant_config = await session.scalar( + select(TenantConfig).where(TenantConfig.user_id == "default") + ) + api_key = ( + tenant_config.openai_api_key + if tenant_config and tenant_config.openai_api_key + else os.getenv("OPENAI_API_KEY") + ) or "" embeddings = await generate_embeddings([chunks[0]], api_key) if embeddings: embedding = embeddings[0] @@ -53,8 +66,45 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): # Upsert into database thread_id = await assign_thread_id(session, email_data) - + mailbox_accounts_result = await session.execute( + select(MailboxAccount).where( + MailboxAccount.user_id == owner_user_id, + MailboxAccount.is_active.is_(True), + ) + ) + mailbox_account_id = resolve_mailbox_account_id_for_email( + mailbox_accounts_result.scalars().all(), + sender=email_data["sender"], + reply_to=email_data.get("reply_to"), + recipients=email_data["recipients"], + ) + + if mailbox_account_id is not None: + legacy_existing_result = await session.execute( + select(Email).where( + Email.user_id == owner_user_id, + Email.mailbox_account_id.is_(None), + Email.message_id == email_data["message_id"], + ) + ) + legacy_existing = legacy_existing_result.scalar_one_or_none() + if legacy_existing: + legacy_existing.mailbox_account_id = mailbox_account_id + legacy_existing.sender = email_data["sender"] + legacy_existing.reply_to = email_data.get("reply_to") + legacy_existing.recipients = email_data["recipients"] + legacy_existing.subject = email_data["subject"] + legacy_existing.in_reply_to = email_data.get("in_reply_to") + legacy_existing.references = email_data.get("references") + legacy_existing.thread_id = thread_id + legacy_existing.date = email_data["date"] + legacy_existing.body = email_data["body"] + legacy_existing.embedding = embedding + continue + stmt = insert(Email).values( + user_id=owner_user_id, + mailbox_account_id=mailbox_account_id, message_id=email_data["message_id"], sender=email_data["sender"], reply_to=email_data.get("reply_to"), @@ -67,21 +117,31 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): body=email_data["body"], embedding=embedding, ) - stmt = stmt.on_conflict_do_update( - index_elements=["message_id"], - set_=dict( - sender=stmt.excluded.sender, - reply_to=stmt.excluded.reply_to, - recipients=stmt.excluded.recipients, - subject=stmt.excluded.subject, - in_reply_to=stmt.excluded.in_reply_to, - references=stmt.excluded.references, - thread_id=stmt.excluded.thread_id, - date=stmt.excluded.date, - body=stmt.excluded.body, - embedding=stmt.excluded.embedding, - ), + update_values = dict( + user_id=stmt.excluded.user_id, + mailbox_account_id=stmt.excluded.mailbox_account_id, + sender=stmt.excluded.sender, + reply_to=stmt.excluded.reply_to, + recipients=stmt.excluded.recipients, + subject=stmt.excluded.subject, + in_reply_to=stmt.excluded.in_reply_to, + references=stmt.excluded.references, + thread_id=stmt.excluded.thread_id, + date=stmt.excluded.date, + body=stmt.excluded.body, + embedding=stmt.excluded.embedding, ) + if mailbox_account_id is None: + stmt = stmt.on_conflict_do_update( + index_elements=["user_id", "message_id"], + index_where=Email.mailbox_account_id.is_(None), + set_=update_values, + ) + else: + stmt = stmt.on_conflict_do_update( + index_elements=["user_id", "mailbox_account_id", "message_id"], + set_=update_values, + ) await session.execute(stmt) await session.commit() diff --git a/backend/services/email_client.py b/backend/services/email_client.py index cf19adb52..d24cfc815 100644 --- a/backend/services/email_client.py +++ b/backend/services/email_client.py @@ -1,8 +1,25 @@ +import asyncio import base64 import logging from email.message import EmailMessage from typing import TypedDict + import aiosmtplib +from aiosmtplib.smtp import ( + SMTPConnectError, + SMTPConnectResponseError, + SMTPConnectTimeoutError, + SMTPProtocol, + SMTPServerDisconnected, + SMTPStatus, + SMTPTimeoutError, +) + +from services.mail_server_security import ( + MailServerConnectTarget, + MailServerValidationError, + resolve_mail_server_connect_target, +) logger = logging.getLogger(__name__) @@ -23,6 +40,100 @@ class SendEmailResult(TypedDict): simulated: bool +class _PinnedSMTP(aiosmtplib.SMTP): + def __init__( + self, + *, + connect_host: str, + tls_server_hostname: str, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self._connect_host = connect_host + self._tls_server_hostname = tls_server_hostname + + async def _create_connection(self, timeout: float | None): + if self.loop is None: + raise RuntimeError("No event loop set") + + protocol = SMTPProtocol(loop=self.loop) + tls_context = None + ssl_handshake_timeout = None + if self.use_tls: + tls_context = self._get_tls_context() + ssl_handshake_timeout = timeout + + if self.sock is not None or self.socket_path is not None: + return await super()._create_connection(timeout) + if self.port is None: + raise RuntimeError("No port provided; default should have been set") + + connect_coro = self.loop.create_connection( + lambda: protocol, + host=self._connect_host, + port=self.port, + ssl=tls_context, + ssl_handshake_timeout=ssl_handshake_timeout, + local_addr=self.source_address, + server_hostname=( + self._tls_server_hostname if tls_context is not None else None + ), + ) + + try: + transport, _ = await asyncio.wait_for(connect_coro, timeout=timeout) + except (TimeoutError, asyncio.TimeoutError) as exc: + raise SMTPConnectTimeoutError( + f"Timed out connecting to {self.hostname} on port {self.port}" + ) from exc + except OSError as exc: + raise SMTPConnectError( + f"Error connecting to {self.hostname} on port {self.port}: {exc}" + ) from exc + + self.protocol = protocol + self.transport = transport + + try: + response = await protocol.read_response(timeout=timeout) + except SMTPServerDisconnected as exc: + raise SMTPConnectError( + f"Error connecting to {self.hostname} on port {self.port}: {exc}" + ) from exc + except SMTPTimeoutError as exc: + raise SMTPConnectTimeoutError( + "Timed out waiting for server ready message" + ) from exc + + if response.code != SMTPStatus.ready: + raise SMTPConnectResponseError(response.code, response.message) + + return response + + +async def _send_message_via_validated_smtp( + message: EmailMessage, + target: MailServerConnectTarget, + username: str | None, + password: str | None, +) -> None: + smtp = _PinnedSMTP( + hostname=target.host, + port=target.port, + connect_host=target.connect_host, + tls_server_hostname=target.host, + use_tls=True, + username=username, + password=password, + ) + try: + await smtp.connect() + await smtp.send_message(message) + finally: + if smtp.is_connected: + await smtp.quit() + + def build_email_message( to_address: str, subject: str, @@ -67,22 +178,26 @@ async def send_email( ) if not smtp_server or not smtp_port: - logger.info("Simulating sending email to %s (no SMTP server configured)", safe_to_address) + logger.info( + "Simulating sending email to %s (no SMTP server configured)", + safe_to_address, + ) return {"status": "simulated", "simulated": True} try: - send_kwargs: dict[str, object] = { - "hostname": smtp_server, - "port": smtp_port, - "use_tls": True, - } - if smtp_username: - send_kwargs["username"] = smtp_username - if smtp_password: - send_kwargs["password"] = smtp_password - - await aiosmtplib.send(message, **send_kwargs) - logger.info("Successfully sent email to %s via %s", safe_to_address, smtp_server) + try: + target = resolve_mail_server_connect_target( + "smtp", "SMTP", smtp_server, smtp_port + ) + except MailServerValidationError as exc: + raise Exception(str(exc)) from exc + + await _send_message_via_validated_smtp( + message, target, smtp_username, smtp_password + ) + logger.info( + "Successfully sent email to %s via %s", safe_to_address, target.host + ) return {"status": "sent", "simulated": False} except Exception as e: raise Exception(f"Failed to send email: {e}") diff --git a/backend/services/email_parser.py b/backend/services/email_parser.py index 6fe323557..7f9b2ba56 100644 --- a/backend/services/email_parser.py +++ b/backend/services/email_parser.py @@ -1,4 +1,5 @@ import email +from html.parser import HTMLParser from email import policy from pathlib import Path import datetime @@ -24,12 +25,51 @@ class EmailData(TypedDict): def _sanitize_nul(text: str) -> str: - """Removes NUL characters from strings, which are invalid in PostgreSQL text fields.""" + """Removes NUL characters from strings before PostgreSQL text storage.""" if not isinstance(text, str): text = str(text) if text is not None else "" return text.replace("\x00", "") +class _HTMLBodyTextExtractor(HTMLParser): + """Extracts display text from untrusted email HTML without executable markup.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self._parts: list[str] = [] + self._skip_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag.lower() in {"script", "style"}: + self._skip_depth += 1 + return + if tag.lower() in {"br", "div", "li", "p", "tr"}: + self._parts.append("\n") + + def handle_endtag(self, tag: str) -> None: + if tag.lower() in {"script", "style"} and self._skip_depth > 0: + self._skip_depth -= 1 + return + if tag.lower() in {"div", "li", "p", "tr"}: + self._parts.append("\n") + + def handle_data(self, data: str) -> None: + if self._skip_depth == 0: + self._parts.append(data) + + def text(self) -> str: + return "".join(self._parts) + + +def sanitize_email_html_to_text(html_body: str) -> str: + """Converts untrusted email HTML into non-executable display text.""" + parser = _HTMLBodyTextExtractor() + parser.feed(html_body) + parser.close() + lines = [line.strip() for line in parser.text().splitlines()] + return "\n".join(line for line in lines if line) + + def parse_eml(file_path: str | Path) -> EmailData: """Parses an EML file and extracts email metadata and body. @@ -80,7 +120,7 @@ def parse_eml(file_path: str | Path) -> EmailData: else: plain_body = part_content - body = plain_body if plain_body else html_body + body = plain_body if plain_body else sanitize_email_html_to_text(html_body) date_header = msg.get("Date") parsed_date = None @@ -92,25 +132,25 @@ def parse_eml(file_path: str | Path) -> EmailData: if not parsed_date: parsed_date = datetime.datetime.now(datetime.timezone.utc) - + message_id = _sanitize_nul(msg.get("Message-ID", "")) - + # Extract thread_id thread_id = None - references = msg.get("References") # O3: email threading support + references = msg.get("References") # O3: email threading support in_reply_to = msg.get("In-Reply-To") - + if references: # Get the first reference as the root thread ID refs = references.split() if refs: thread_id = _sanitize_nul(refs[0]) - + if not thread_id and in_reply_to: in_reply_to_list = in_reply_to.split() if in_reply_to_list: thread_id = _sanitize_nul(in_reply_to_list[0]) - + if not thread_id: thread_id = message_id @@ -118,11 +158,17 @@ def parse_eml(file_path: str | Path) -> EmailData: "message_id": message_id, "thread_id": thread_id, "sender": _sanitize_nul(msg.get("From", "")), - "reply_to": _sanitize_nul(msg.get("Reply-To", "")) if msg.get("Reply-To") else None, + "reply_to": _sanitize_nul(msg.get("Reply-To", "")) + if msg.get("Reply-To") + else None, "recipients": _sanitize_nul(msg.get("To", "")), "subject": _sanitize_nul(msg.get("Subject", "")), - "in_reply_to": _sanitize_nul(msg.get("In-Reply-To", "")) if msg.get("In-Reply-To") else None, - "references": _sanitize_nul(msg.get("References", "")) if msg.get("References") else None, + "in_reply_to": _sanitize_nul(msg.get("In-Reply-To", "")) + if msg.get("In-Reply-To") + else None, + "references": _sanitize_nul(msg.get("References", "")) + if msg.get("References") + else None, "date": parsed_date, "body": _sanitize_nul(body), "attachments": attachments, diff --git a/backend/services/imap_worker.py b/backend/services/imap_worker.py index 6e6eab53b..74d34ce4a 100644 --- a/backend/services/imap_worker.py +++ b/backend/services/imap_worker.py @@ -1,13 +1,61 @@ import asyncio import logging +import ssl +from typing import cast + import aioimaplib from sqlalchemy import select from db.session import AsyncSessionLocal -from db.models import TenantConfig +from db.models import MailboxAccount +from services.mail_server_security import ( + MailServerConnectTarget, + MailServerValidationError, + resolve_mail_server_connect_target, +) logger = logging.getLogger(__name__) +class _PinnedIMAP4_SSL(aioimaplib.IMAP4_SSL): + def __init__( + self, + host: str, + port: int, + connect_host: str, + loop: asyncio.AbstractEventLoop | None = None, + timeout: float = aioimaplib.IMAP4.TIMEOUT_SECONDS, + ssl_context: ssl.SSLContext | None = None, + ) -> None: + self._connect_host = connect_host + super().__init__(host, port, loop, timeout, ssl_context) + + def create_client( + self, + host: str, + port: int, + loop: asyncio.AbstractEventLoop | None, + conn_lost_cb=None, + ssl_context: ssl.SSLContext | None = None, + ) -> None: + if ssl_context is None: + ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + local_loop = loop if loop is not None else asyncio.get_running_loop() + self.protocol = aioimaplib.IMAP4ClientProtocol(local_loop, conn_lost_cb) + local_loop.create_task( + local_loop.create_connection( + lambda: self.protocol, + self._connect_host, + port, + ssl=ssl_context, + server_hostname=host, + ) + ) + + +def _create_imap_ssl_client(target: MailServerConnectTarget): + return _PinnedIMAP4_SSL(target.host, target.port, target.connect_host) + + class ImapSyncWorker: def __init__(self): self._task = None @@ -53,54 +101,88 @@ async def _run_loop(self): async def _sync(self): async with AsyncSessionLocal() as session: - configs = await session.execute(select(TenantConfig).where(TenantConfig.imap_server.isnot(None))) - + accounts = await session.execute( + select(MailboxAccount).where( + MailboxAccount.is_active.is_(True), + MailboxAccount.imap_server.isnot(None), + ) + ) + tasks = [] - for config in configs.scalars(): - if not config.imap_server or not config.imap_port: + for account in accounts.scalars().all(): + if ( + not account.is_active + or not account.imap_server + or not account.imap_port + ): continue - tasks.append(self._sync_tenant(config)) - + tasks.append(self._sync_mailbox_account(account)) + if tasks: await asyncio.gather(*tasks, return_exceptions=True) - async def _sync_tenant(self, config: TenantConfig): + async def _sync_mailbox_account(self, account: MailboxAccount): # Already verified not None in caller - imap_server = str(config.imap_server) - imap_port = int(config.imap_port) # type: ignore - + if not account.imap_server or not account.imap_port: + raise Exception( + f"IMAP Sync failed for user {account.user_id} mailbox {account.email_address}: IMAP server/port missing" + ) + imap_server = str(account.imap_server) + imap_port = cast(int, account.imap_port) + + try: + target = resolve_mail_server_connect_target( + "imap", "IMAP", imap_server, imap_port + ) + except MailServerValidationError as exc: + raise Exception( + f"IMAP Sync failed for user {account.user_id} mailbox {account.email_address}: {exc}" + ) from exc + logger.info( - f"Connecting to IMAP server {imap_server}:{imap_port} for user {config.user_id}" + f"Connecting to IMAP server {target.host}:{target.port} for user {account.user_id} mailbox {account.email_address}" ) - imap_client = aioimaplib.IMAP4_SSL(imap_server, imap_port) + imap_client = _create_imap_ssl_client(target) try: await imap_client.wait_hello_from_server() - logger.info(f"Successfully connected to IMAP server for user {config.user_id}.") - + logger.info( + f"Successfully connected to IMAP server for user {account.user_id} mailbox {account.email_address}." + ) + # Since this is a test/demo setup, we expect real IMAP to fail if invalid or missing creds. - # But the requirement is to actually connect. - if config.imap_username and config.imap_password: - resp, data = await imap_client.login(config.imap_username, config.imap_password) + # But the requirement is to actually connect. + if account.imap_username and account.imap_password: + resp, data = await imap_client.login( + account.imap_username, account.imap_password + ) if resp != "OK": raise Exception(f"IMAP login failed: {data}") - + await imap_client.select("INBOX") - + # Search for recent emails (e.g., last 10) # For simplicity, we just fetch a small batch to prove connectivity # Real parser logic would go here. else: - logger.info(f"No IMAP credentials provided for user {config.user_id}, skipping login.") + logger.info( + f"No IMAP credentials provided for user {account.user_id} mailbox {account.email_address}, skipping login." + ) # Actual sync logic will be added here later except Exception as e: - logger.error(f"Failed to connect or sync with IMAP server for user {config.user_id}: {e}") - raise Exception(f"IMAP Sync failed for user {config.user_id}: {e}") from e + logger.error( + f"Failed to connect or sync with IMAP server for user {account.user_id} mailbox {account.email_address}: {e}" + ) + raise Exception( + f"IMAP Sync failed for user {account.user_id} mailbox {account.email_address}: {e}" + ) from e finally: try: if hasattr(imap_client, "protocol") and imap_client.protocol: await imap_client.logout() except Exception as logout_err: - logger.warning(f"Error during IMAP logout for user {config.user_id}: {logout_err}") + logger.warning( + f"Error during IMAP logout for user {account.user_id} mailbox {account.email_address}: {logout_err}" + ) diff --git a/backend/services/mail_server_security.py b/backend/services/mail_server_security.py new file mode 100644 index 000000000..a2de05933 --- /dev/null +++ b/backend/services/mail_server_security.py @@ -0,0 +1,91 @@ +import ipaddress +import socket +from dataclasses import dataclass + +INTERNAL_MAIL_SERVER_MESSAGE = "메일 서버 주소는 내부 네트워크를 사용할 수 없습니다." + +ALLOWED_MAIL_PORTS = { + "smtp": {25, 465, 587, 2525}, + "imap": {143, 993}, + "pop3": {110, 995}, +} + + +class MailServerValidationError(ValueError): + """Raised when a user-configured mail server is unsafe to connect to.""" + + +@dataclass(frozen=True) +class MailServerConnectTarget: + """A validated mail endpoint with the exact IP to use for connection.""" + + host: str + port: int + connect_host: str + + +def _is_internal_mail_ip(address: str) -> bool: + try: + ip = ipaddress.ip_address(address) + except ValueError: + return False + return ip.is_multicast or not ip.is_global + + +def _validate_mail_server_syntax_and_port( + prefix: str, label: str, host: str, port: int +) -> str: + normalized_host = host.strip().rstrip(".").lower() + if ( + not normalized_host + or normalized_host in {"localhost", "localhost.localdomain"} + or normalized_host.endswith(".localhost") + or any(character in normalized_host for character in ("/", "\\", "@", "#")) + or any(character.isspace() for character in normalized_host) + or _is_internal_mail_ip(normalized_host) + ): + raise MailServerValidationError(INTERNAL_MAIL_SERVER_MESSAGE) + + allowed_ports = ALLOWED_MAIL_PORTS[prefix] + if port not in allowed_ports: + raise MailServerValidationError( + f"{label} 포트는 허용된 메일 포트만 사용할 수 있습니다." + ) + return normalized_host + + +def _resolve_safe_mail_server_ips(host: str, port: int) -> list[str]: + try: + resolved_addresses = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except socket.gaierror: + return [] + + safe_ips: list[str] = [] + for *_, sockaddr in resolved_addresses: + resolved_ip = sockaddr[0] + if _is_internal_mail_ip(resolved_ip): + raise MailServerValidationError(INTERNAL_MAIL_SERVER_MESSAGE) + safe_ips.append(resolved_ip) + return safe_ips + + +def validate_mail_server_host(prefix: str, label: str, host: str, port: int) -> str: + """Normalize and validate a mail server for persistence-time API checks.""" + normalized_host = _validate_mail_server_syntax_and_port(prefix, label, host, port) + _resolve_safe_mail_server_ips(normalized_host, port) + return normalized_host + + +def resolve_mail_server_connect_target( + prefix: str, label: str, host: str, port: int +) -> MailServerConnectTarget: + """Resolve and pin a safe mail server IP immediately before connecting.""" + normalized_host = _validate_mail_server_syntax_and_port(prefix, label, host, port) + safe_ips = _resolve_safe_mail_server_ips(normalized_host, port) + if not safe_ips: + raise MailServerValidationError(f"{label} 서버 주소를 확인할 수 없습니다.") + return MailServerConnectTarget( + host=normalized_host, + port=port, + connect_host=safe_ips[0], + ) diff --git a/backend/services/mailbox_routing.py b/backend/services/mailbox_routing.py new file mode 100644 index 000000000..95c06611a --- /dev/null +++ b/backend/services/mailbox_routing.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import re + +from collections.abc import Sequence + +from db.models import MailboxAccount + + +def _extract_mailboxes(value: str | None) -> set[str]: + if not value: + return set() + mailboxes = set() + for token in re.split(r"[,;]", value): + match = re.search(r"<([^>]+)>", token) + mailbox = (match.group(1) if match else token).strip().lower() + if mailbox: + mailboxes.add(mailbox) + return mailboxes + + +def _account_mailboxes(account: MailboxAccount) -> set[str]: + return { + mailbox.lower() + for mailbox in [ + account.email_address, + account.smtp_username, + account.imap_username, + ] + if mailbox + } + + +def resolve_mailbox_account_id_for_email( + accounts: Sequence[MailboxAccount], + *, + sender: str | None, + reply_to: str | None, + recipients: str | None, +) -> int | None: + active_accounts = [ + account for account in accounts if getattr(account, "is_active", True) + ] + if not active_accounts: + return None + + email_mailboxes = set() + email_mailboxes |= _extract_mailboxes(sender) + email_mailboxes |= _extract_mailboxes(reply_to) + email_mailboxes |= _extract_mailboxes(recipients) + + matches = [ + account + for account in active_accounts + if not _account_mailboxes(account).isdisjoint(email_mailboxes) + ] + if matches: + default_match = next( + (account for account in matches if account.is_default_reply), None + ) + return (default_match or matches[0]).id + + if len(active_accounts) == 1: + return active_accounts[0].id + + default_account = next( + (account for account in active_accounts if account.is_default_reply), None + ) + return default_account.id if default_account else None diff --git a/backend/services/pop3_worker.py b/backend/services/pop3_worker.py index f3a83f067..3d0eccd07 100644 --- a/backend/services/pop3_worker.py +++ b/backend/services/pop3_worker.py @@ -1,14 +1,58 @@ import asyncio import logging +import socket +import ssl +from typing import cast + import poplib from sqlalchemy import select from db.session import AsyncSessionLocal -from db.models import TenantConfig +from db.models import MailboxAccount +from services.mail_server_security import ( + MailServerConnectTarget, + MailServerValidationError, + resolve_mail_server_connect_target, +) logger = logging.getLogger(__name__) +class _PinnedPOP3_SSL(poplib.POP3_SSL): + def __init__( + self, + host: str, + port: int, + connect_host: str, + timeout: float | int | None = None, + context: ssl.SSLContext | None = None, + ) -> None: + self._connect_host = connect_host + self._ssl_context = context or ssl.create_default_context( + ssl.Purpose.SERVER_AUTH + ) + super().__init__(host, port, timeout=timeout, context=self._ssl_context) + + def _create_socket(self, timeout): + if timeout is not None and not timeout: + raise ValueError("Non-blocking socket (timeout=0) is not supported") + sock = socket.create_connection((self._connect_host, self.port), timeout) + return self._ssl_context.wrap_socket(sock, server_hostname=self.host) + + +def _create_pop3_ssl_client(target: MailServerConnectTarget, timeout: int): + context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + return _PinnedPOP3_SSL( + target.host, + target.port, + target.connect_host, + timeout=timeout, + context=context, + ) + + class Pop3SyncWorker: + SOCKET_TIMEOUT_SECONDS = 15 + def __init__(self): self._task = None self._is_running = False @@ -52,40 +96,79 @@ async def _run_loop(self): async def _sync(self): async with AsyncSessionLocal() as session: - configs = await session.execute(select(TenantConfig).where(TenantConfig.pop3_server.isnot(None))) - + accounts = await session.execute( + select(MailboxAccount).where( + MailboxAccount.is_active.is_(True), + MailboxAccount.pop3_server.isnot(None), + ) + ) + semaphore = asyncio.Semaphore(10) tasks = [] - for config in configs.scalars(): - if not config.pop3_server or not config.pop3_port: + for account in accounts.scalars().all(): + if ( + not account.is_active + or not account.pop3_server + or not account.pop3_port + ): continue - tasks.append(self._sync_tenant(config, semaphore)) - + tasks.append(self._sync_mailbox_account(account, semaphore)) + if tasks: await asyncio.gather(*tasks, return_exceptions=True) - async def _sync_tenant(self, config: TenantConfig, semaphore: asyncio.Semaphore): + async def _sync_mailbox_account( + self, account: MailboxAccount, semaphore: asyncio.Semaphore + ): async with semaphore: - pop3_server = str(config.pop3_server) - pop3_port = int(config.pop3_port) # type: ignore + if not account.pop3_server or not account.pop3_port: + raise Exception( + f"POP3 Sync failed for user {account.user_id} mailbox {account.email_address}: POP3 server/port missing" + ) + pop3_server = str(account.pop3_server) + pop3_port = cast(int, account.pop3_port) logger.info( - f"Connecting to POP3 server {pop3_server}:{pop3_port} for user {config.user_id}" + f"Connecting to POP3 server {pop3_server}:{pop3_port} for user {account.user_id} mailbox {account.email_address}" ) try: # We use asyncio.to_thread for synchronous poplib - await asyncio.to_thread(self._do_pop3_sync, config) - logger.info(f"Successfully connected to POP3 server for user {config.user_id}.") + await asyncio.to_thread(self._do_pop3_sync, account) + logger.info( + f"Successfully connected to POP3 server for user {account.user_id} mailbox {account.email_address}." + ) except Exception as e: - logger.error(f"Failed to connect or sync with POP3 server for user {config.user_id}: {e}") + logger.error( + f"Failed to connect or sync with POP3 server for user {account.user_id} mailbox {account.email_address}: {e}" + ) + raise Exception( + f"POP3 Sync failed for user {account.user_id} mailbox {account.email_address}: {e}" + ) from e + + def _resolve_pop3_target(self, account: MailboxAccount) -> MailServerConnectTarget: + if not account.pop3_server or not account.pop3_port: + raise Exception( + f"POP3 Sync failed for user {account.user_id} mailbox {account.email_address}: POP3 server/port missing" + ) + pop3_server = str(account.pop3_server) + pop3_port = cast(int, account.pop3_port) + try: + return resolve_mail_server_connect_target( + "pop3", "POP3", pop3_server, pop3_port + ) + except MailServerValidationError as exc: + raise Exception( + f"POP3 Sync failed for user {account.user_id} mailbox {account.email_address}: {exc}" + ) from exc - def _do_pop3_sync(self, config: TenantConfig): - pop3_server = str(config.pop3_server) - pop3_port = int(config.pop3_port) # type: ignore - pop3_client = poplib.POP3_SSL(pop3_server, pop3_port) + def _do_pop3_sync(self, account: MailboxAccount): + target = self._resolve_pop3_target(account) + pop3_client = _create_pop3_ssl_client( + target, timeout=self.SOCKET_TIMEOUT_SECONDS + ) try: # Note: Real implementation would use OAuth or password. - # pop3_client.user(config.pop3_username) - # pop3_client.pass_(config.pop3_password) + # pop3_client.user(account.pop3_username) + # pop3_client.pass_(account.pop3_password) pass finally: pop3_client.quit() diff --git a/backend/tests/auth_helpers.py b/backend/tests/auth_helpers.py new file mode 100644 index 000000000..1a01906d7 --- /dev/null +++ b/backend/tests/auth_helpers.py @@ -0,0 +1,44 @@ +import datetime + +import jwt + +from core.config import settings + +TEST_AUTH_AUDIENCE = "naruon-web" +TEST_AUTH_ISSUER = "https://issuer.example.com/realms/naruon" +TEST_AUTH_SECRET = "test-secret" + + +def configure_test_auth() -> None: + settings.AUTH_MODE = "hybrid" + settings.TRUST_DEV_HEADERS = False + settings.OIDC_SHARED_SECRET = TEST_AUTH_SECRET + settings.OIDC_ISSUER = TEST_AUTH_ISSUER + settings.OIDC_AUDIENCE = TEST_AUTH_AUDIENCE + settings.OIDC_JWKS_URL = None + + +def auth_headers( + user_id: str = "testuser", + role: str = "member", + organization_id: str | None = None, + group_ids: tuple[str, ...] = (), +) -> dict[str, str]: + payload: dict[str, object] = { + "sub": user_id, + "iss": TEST_AUTH_ISSUER, + "aud": TEST_AUTH_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "roles": [role], + } + if organization_id is not None: + payload["organization_id"] = organization_id + if group_ids: + payload["groups"] = list(group_ids) + token = jwt.encode(payload, TEST_AUTH_SECRET, algorithm="HS256") + return {"Authorization": f"Bearer {token}"} diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 000000000..2b9947d40 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest + +from core.config import settings +from tests.auth_helpers import configure_test_auth + + +@pytest.fixture(autouse=True) +def enable_bearer_auth_for_repo_tests(): + previous_auth_mode = settings.AUTH_MODE + previous_trust = settings.TRUST_DEV_HEADERS + previous_secret = settings.OIDC_SHARED_SECRET + previous_issuer = settings.OIDC_ISSUER + previous_audience = settings.OIDC_AUDIENCE + previous_jwks_url = settings.OIDC_JWKS_URL + configure_test_auth() + yield + settings.AUTH_MODE = previous_auth_mode + settings.TRUST_DEV_HEADERS = previous_trust + settings.OIDC_SHARED_SECRET = previous_secret + settings.OIDC_ISSUER = previous_issuer + settings.OIDC_AUDIENCE = previous_audience + settings.OIDC_JWKS_URL = previous_jwks_url diff --git a/backend/tests/live/seed_live_data.py b/backend/tests/live/seed_live_data.py index 84c80e3d7..a4f632527 100644 --- a/backend/tests/live/seed_live_data.py +++ b/backend/tests/live/seed_live_data.py @@ -10,7 +10,6 @@ from db.models import Email from db.session import AsyncSessionLocal - THREAD_ID = "" MESSAGE_IDS = [ "", @@ -24,6 +23,7 @@ async def seed_live_data() -> None: session.add_all( [ Email( + user_id="testuser", message_id=MESSAGE_IDS[0], thread_id=THREAD_ID, sender="ops@example.test", @@ -34,6 +34,7 @@ async def seed_live_data() -> None: embedding=[0.0] * 1536, ), Email( + user_id="testuser", message_id=MESSAGE_IDS[1], thread_id=THREAD_ID, sender="swe@example.test", diff --git a/backend/tests/test_apm_observability.py b/backend/tests/test_apm_observability.py index f15f4f5f4..41a334673 100644 --- a/backend/tests/test_apm_observability.py +++ b/backend/tests/test_apm_observability.py @@ -1,20 +1,24 @@ -import pytest -import subprocess from pathlib import Path + def test_observability_compose_file_exists(): assert Path("../docker-compose.observability.yml").exists() + def test_observability_provisioning_exists(): - assert Path("../observability/grafana/provisioning/datasources/datasources.yaml").exists() + assert Path( + "../observability/grafana/provisioning/datasources/datasources.yaml" + ).exists() assert Path("../observability/prometheus.yml").exists() assert Path("../observability/tempo.yaml").exists() + def test_backend_exposes_metrics_endpoint(): from main import app from fastapi.testclient import TestClient - - with TestClient(app, headers={"X-User-Id": "testuser"}) as client: + from tests.auth_helpers import auth_headers + + with TestClient(app, headers=auth_headers("testuser")) as client: # Before instrumentation, it might be 404, but after it should be 200 response = client.get("/metrics") # Ensure it is added as part of the APM implementation diff --git a/backend/tests/test_archive.py b/backend/tests/test_archive.py index eb94b2702..fd054282a 100644 --- a/backend/tests/test_archive.py +++ b/backend/tests/test_archive.py @@ -1,8 +1,6 @@ -import os import zipfile import pytest import asyncio -from pathlib import Path from services.archive import extract_backup, extract_backup_async from services.exceptions import ( diff --git a/backend/tests/test_auth_real.py b/backend/tests/test_auth_real.py index 73afcb376..0ab5e9811 100644 --- a/backend/tests/test_auth_real.py +++ b/backend/tests/test_auth_real.py @@ -1,6 +1,23 @@ +import json +import hmac +import hashlib +import base64 +import datetime +import inspect +from types import SimpleNamespace + import pytest +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa from fastapi import HTTPException -from api.auth import AuthContext, ensure_organization_access, get_auth_context, get_current_user +from jwt.algorithms import RSAAlgorithm +import api.auth as auth_module +from api.auth import ( + AuthContext, + ensure_organization_access, + get_auth_context, + get_current_user, +) from core.config import settings @@ -8,29 +25,74 @@ def restore_auth_flags(): previous_debug = settings.DEBUG previous_trust = settings.TRUST_DEV_HEADERS + previous_auth_mode = settings.AUTH_MODE + previous_jwt_secret = getattr(settings, "OIDC_SHARED_SECRET", None) + previous_issuer = getattr(settings, "OIDC_ISSUER", None) + previous_audience = getattr(settings, "OIDC_AUDIENCE", None) + previous_jwks_url = getattr(settings, "OIDC_JWKS_URL", None) yield settings.DEBUG = previous_debug settings.TRUST_DEV_HEADERS = previous_trust + settings.AUTH_MODE = previous_auth_mode + settings.OIDC_SHARED_SECRET = previous_jwt_secret + settings.OIDC_ISSUER = previous_issuer + settings.OIDC_AUDIENCE = previous_audience + settings.OIDC_JWKS_URL = previous_jwks_url + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def _encode_test_jwt(payload: dict, secret: str) -> str: + header = {"alg": "HS256", "typ": "JWT"} + header_part = _b64url(json.dumps(header, separators=(",", ":")).encode()) + payload_part = _b64url(json.dumps(payload, separators=(",", ":")).encode()) + signing_input = f"{header_part}.{payload_part}".encode() + signature = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest() + return f"{header_part}.{payload_part}.{_b64url(signature)}" + + +def _build_test_jwk(public_key, kid: str) -> dict: + jwk = json.loads(RSAAlgorithm.to_jwk(public_key)) + jwk.update({"kid": kid, "use": "sig", "alg": "RS256"}) + return jwk + @pytest.mark.asyncio async def test_get_current_user_rejects_missing_auth(): - # It should raise HTTP 401 when no auth is provided, rather than defaulting to "default". with pytest.raises(HTTPException) as exc: - await get_current_user(x_user_id=None) + await get_current_user() assert exc.value.status_code == 401 @pytest.mark.asyncio -async def test_get_auth_context_supports_scoped_enterprise_roles(): - settings.TRUST_DEV_HEADERS = True +async def test_get_auth_context_supports_scoped_enterprise_roles_from_bearer_claims(): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" - context = await get_auth_context( - x_user_id="alice", - x_user_role="group_admin", - x_organization_id="org-acme", - x_group_ids="group-1,group-2", + token = _encode_test_jwt( + { + "sub": "alice", + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "organization_id": "org-acme", + "groups": ["group-1", "group-2"], + "roles": ["group_admin"], + }, + settings.OIDC_SHARED_SECRET, ) + context = await get_auth_context(authorization=f"Bearer {token}") + assert context == AuthContext( user_id="alice", role="group_admin", @@ -41,14 +103,30 @@ async def test_get_auth_context_supports_scoped_enterprise_roles(): @pytest.mark.asyncio -async def test_get_auth_context_keeps_legacy_workspace_fallback_for_unscoped_dev_auth(): - settings.TRUST_DEV_HEADERS = True +async def test_get_auth_context_keeps_workspace_fallback_for_unscoped_bearer_claims(): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" - context = await get_auth_context( - x_user_id="root", - x_user_role="platform_admin", + token = _encode_test_jwt( + { + "sub": "root", + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "roles": ["platform_admin"], + }, + settings.OIDC_SHARED_SECRET, ) + context = await get_auth_context(authorization=f"Bearer {token}") + assert context.role == "platform_admin" assert context.organization_id is None assert context.group_ids == () @@ -69,3 +147,468 @@ def test_ensure_organization_access_rejects_cross_scope_resource(): assert exc.value.status_code == 403 assert exc.value.detail == "Resource belongs to a different organization" + + +@pytest.mark.asyncio +async def test_get_auth_context_accepts_valid_bearer_token_claims(): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" + + token = _encode_test_jwt( + { + "sub": "alice", + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "organization_id": "org-acme", + "groups": ["group-1", "group-2"], + "roles": ["organization_admin"], + }, + settings.OIDC_SHARED_SECRET, + ) + + context = await get_auth_context(authorization=f"Bearer {token}") + + assert context == AuthContext( + user_id="alice", + role="organization_admin", + organization_id="org-acme", + group_ids=("group-1", "group-2"), + workspace_id="workspace-org-acme", + ) + + +@pytest.mark.asyncio +async def test_get_auth_context_does_not_promote_admin_subject_without_role_claims(): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" + + token = _encode_test_jwt( + { + "sub": "admin", + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "organization_id": "org-acme", + }, + settings.OIDC_SHARED_SECRET, + ) + + context = await get_auth_context(authorization=f"Bearer {token}") + + assert context.role == "member" + + +@pytest.mark.asyncio +async def test_get_auth_context_accepts_valid_jwks_bearer_token_claims(monkeypatch): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = None + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" + settings.OIDC_JWKS_URL = ( + "https://issuer.example.com/realms/naruon/protocol/openid-connect/certs" + ) + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + kid = "test-key-1" + token = jwt.encode( + { + "sub": "oidc-admin", + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "organization_id": "org-acme", + "roles": ["organization_admin"], + }, + private_key, + algorithm="RS256", + headers={"kid": kid}, + ) + + def fake_fetch_data(_self): + return {"keys": [_build_test_jwk(private_key.public_key(), kid)]} + + monkeypatch.setattr(jwt.PyJWKClient, "fetch_data", fake_fetch_data) + + context = await get_auth_context(authorization=f"Bearer {token}") + + assert context == AuthContext( + user_id="oidc-admin", + role="organization_admin", + organization_id="org-acme", + group_ids=(), + workspace_id="workspace-org-acme", + ) + + +@pytest.mark.asyncio +async def test_get_auth_context_uses_async_boundary_for_jwks_lookup(monkeypatch): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = None + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" + settings.OIDC_JWKS_URL = ( + "https://issuer.example.com/realms/naruon/protocol/openid-connect/certs" + ) + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + kid = "test-key-async" + token = jwt.encode( + { + "sub": "oidc-admin", + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "organization_id": "org-acme", + "roles": ["organization_admin"], + }, + private_key, + algorithm="RS256", + headers={"kid": kid}, + ) + + def fake_fetch_data(_self): + return {"keys": [_build_test_jwk(private_key.public_key(), kid)]} + + to_thread_called = {"value": False} + + async def fake_to_thread(func, *args, **kwargs): + to_thread_called["value"] = True + return func(*args, **kwargs) + + monkeypatch.setattr(jwt.PyJWKClient, "fetch_data", fake_fetch_data) + monkeypatch.setattr("api.auth.asyncio.to_thread", fake_to_thread) + + context = await get_auth_context(authorization=f"Bearer {token}") + + assert context.user_id == "oidc-admin" + assert to_thread_called["value"] is True + + +@pytest.mark.asyncio +async def test_get_auth_context_reuses_cached_jwks_client(monkeypatch): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = None + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" + settings.OIDC_JWKS_URL = ( + "https://issuer.example.com/realms/naruon/protocol/openid-connect/certs" + ) + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + kid = "test-key-cache" + token = jwt.encode( + { + "sub": "oidc-admin", + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "organization_id": "org-acme", + "roles": ["organization_admin"], + }, + private_key, + algorithm="RS256", + headers={"kid": kid}, + ) + + def fake_fetch_data(_self): + return {"keys": [_build_test_jwk(private_key.public_key(), kid)]} + + original_client = jwt.PyJWKClient + constructor_calls = {"count": 0} + + class CountingPyJWKClient(original_client): + def __init__(self, uri, *args, **kwargs): + constructor_calls["count"] += 1 + super().__init__(uri, *args, **kwargs) + + monkeypatch.setattr(jwt.PyJWKClient, "fetch_data", fake_fetch_data) + monkeypatch.setattr("api.auth.jwt.PyJWKClient", CountingPyJWKClient) + from api.auth import _get_jwk_client + + _get_jwk_client.cache_clear() + + await get_auth_context(authorization=f"Bearer {token}") + await get_auth_context(authorization=f"Bearer {token}") + + assert constructor_calls["count"] == 1 + + +@pytest.mark.asyncio +async def test_get_auth_context_rejects_invalid_bearer_signature(): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" + + token = _encode_test_jwt( + { + "sub": "mallory", + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "organization_id": "org-acme", + "roles": ["platform_admin"], + }, + "wrong-secret", + ) + + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_auth_context_rejects_hs256_when_jwks_url_is_configured(): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" + settings.OIDC_JWKS_URL = ( + "https://issuer.example.com/realms/naruon/protocol/openid-connect/certs" + ) + + token = _encode_test_jwt( + { + "sub": "mallory", + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "organization_id": "org-acme", + "roles": ["platform_admin"], + }, + settings.OIDC_SHARED_SECRET, + ) + + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + + assert exc.value.status_code == 401 + assert exc.value.detail == "Invalid bearer token" + + +@pytest.mark.asyncio +async def test_get_auth_context_rejects_dev_headers_when_not_trusted(): + settings.DEBUG = False + settings.TRUST_DEV_HEADERS = False + settings.AUTH_MODE = "hybrid" + + with pytest.raises(HTTPException) as exc: + await get_auth_context() + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_auth_context_rejects_dev_headers_even_when_trusted_flag_is_set(): + settings.DEBUG = False + settings.TRUST_DEV_HEADERS = True + settings.AUTH_MODE = "hybrid" + + signature = inspect.signature(get_auth_context) + assert "x_user_id" not in signature.parameters + assert "x_user_role" not in signature.parameters + assert "x_organization_id" not in signature.parameters + assert "x_group_ids" not in signature.parameters + + with pytest.raises(HTTPException) as exc: + await get_auth_context() + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_auth_context_rejects_dev_headers_even_when_debug_is_enabled(): + settings.DEBUG = True + settings.TRUST_DEV_HEADERS = False + settings.AUTH_MODE = "hybrid" + + signature = inspect.signature(get_current_user) + assert "x_user_id" not in signature.parameters + assert "x_user_role" not in signature.parameters + assert "x_organization_id" not in signature.parameters + assert "x_group_ids" not in signature.parameters + + with pytest.raises(HTTPException) as exc: + await get_current_user() + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_auth_context_rejects_header_mode_when_headers_are_not_trusted(): + settings.DEBUG = False + settings.TRUST_DEV_HEADERS = False + settings.AUTH_MODE = "header" + + with pytest.raises(HTTPException) as exc: + await get_auth_context() + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_auth_context_rejects_header_mode_even_when_trusted_flag_is_set(): + settings.DEBUG = False + settings.TRUST_DEV_HEADERS = True + settings.AUTH_MODE = "header" + + with pytest.raises(HTTPException) as exc: + await get_auth_context() + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_auth_context_rejects_bearer_token_without_expiration(): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" + + token = _encode_test_jwt( + { + "sub": "alice", + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "organization_id": "org-acme", + "roles": ["organization_admin"], + }, + settings.OIDC_SHARED_SECRET, + ) + + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_auth_context_rejects_bearer_token_without_configured_issuer(): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_ISSUER = None + settings.OIDC_AUDIENCE = "naruon-web" + + token = _encode_test_jwt( + { + "sub": "alice", + "iss": "https://issuer.example.com/realms/naruon", + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + }, + settings.OIDC_SHARED_SECRET, + ) + + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + + assert exc.value.status_code == 503 + assert exc.value.detail == "OIDC issuer and audience are not configured" + + +@pytest.mark.asyncio +async def test_get_auth_context_rejects_bearer_token_without_configured_audience(): + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = None + + token = _encode_test_jwt( + { + "sub": "alice", + "iss": settings.OIDC_ISSUER, + "aud": "naruon-web", + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + }, + settings.OIDC_SHARED_SECRET, + ) + + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + + assert exc.value.status_code == 503 + assert exc.value.detail == "OIDC issuer and audience are not configured" + + +@pytest.mark.asyncio +async def test_get_auth_context_fails_closed_when_oidc_verifier_settings_are_absent( + monkeypatch, +): + drifted_settings = SimpleNamespace( + AUTH_MODE="oidc", + OIDC_ISSUER="https://issuer.example.com/realms/naruon", + OIDC_AUDIENCE="naruon-web", + ) + monkeypatch.setattr(auth_module, "settings", drifted_settings) + + token = _encode_test_jwt( + { + "sub": "alice", + "iss": drifted_settings.OIDC_ISSUER, + "aud": drifted_settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + }, + "", + ) + + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + + assert exc.value.status_code == 503 + assert exc.value.detail == "OIDC verifier is not configured" diff --git a/backend/tests/test_bootstrap_db.py b/backend/tests/test_bootstrap_db.py index 49c45e184..ea8e59bab 100644 --- a/backend/tests/test_bootstrap_db.py +++ b/backend/tests/test_bootstrap_db.py @@ -4,8 +4,89 @@ def test_schema_backfill_adds_threading_columns_for_existing_tables(): statements = [str(statement).lower() for statement in schema_backfill_sql()] - assert any("alter table emails add column if not exists reply_to" in statement for statement in statements) - assert any("alter table emails add column if not exists thread_id" in statement for statement in statements) - assert any("alter table emails add column if not exists in_reply_to" in statement for statement in statements) - assert any("alter table emails add column if not exists \"references\"" in statement for statement in statements) - assert any("create index if not exists ix_emails_thread_id" in statement for statement in statements) + assert any( + "alter table emails add column if not exists user_id" in statement + for statement in statements + ) + assert any( + "drop index if exists ix_emails_message_id" in statement + for statement in statements + ) + assert any( + "create unique index if not exists uq_emails_owner_message_when_mailbox_null" + in statement + for statement in statements + ) + assert any( + "create unique index if not exists uq_emails_owner_mailbox_message" in statement + for statement in statements + ) + assert any( + "alter table emails add column if not exists reply_to" in statement + for statement in statements + ) + assert any( + "alter table emails add column if not exists thread_id" in statement + for statement in statements + ) + assert any( + "alter table emails add column if not exists in_reply_to" in statement + for statement in statements + ) + assert any( + 'alter table emails add column if not exists "references"' in statement + for statement in statements + ) + assert any( + "create index if not exists ix_emails_thread_id" in statement + for statement in statements + ) + + +def test_schema_backfill_does_not_force_legacy_llm_providers_into_org_local_dev(): + statements = [str(statement).lower() for statement in schema_backfill_sql()] + + assert not any( + "update llm_providers set organization_id = 'org-local-dev'" in statement + for statement in statements + ) + assert any( + "alter table llm_providers drop constraint if exists llm_providers_name_key" + in statement + for statement in statements + ) + assert any( + "create unique index if not exists uq_llm_providers_organization_name" + in statement + for statement in statements + ) + + +def test_schema_backfill_supports_explicit_legacy_llm_provider_org_mapping(): + statements = [ + str(statement).lower() for statement in schema_backfill_sql("org-acme") + ] + + assert any( + "update llm_providers set organization_id" in statement + for statement in statements + ) + + +def test_schema_backfill_supports_explicit_legacy_email_owner_mapping(): + statements = [ + str(statement).lower() for statement in schema_backfill_sql(None, "testuser") + ] + + assert any( + "alter table emails add column if not exists user_id" in statement + for statement in statements + ) + assert any("update emails set user_id" in statement for statement in statements) + + +def test_schema_backfill_fails_closed_when_legacy_email_owner_mapping_is_missing(): + statements = [str(statement).lower() for statement in schema_backfill_sql()] + + assert any("emails where user_id is null" in statement for statement in statements) + assert any("legacy_email_owner_user_id" in statement for statement in statements) diff --git a/backend/tests/test_calendar_api.py b/backend/tests/test_calendar_api.py index 2b5354df7..394b904c8 100644 --- a/backend/tests/test_calendar_api.py +++ b/backend/tests/test_calendar_api.py @@ -1,20 +1,33 @@ -import pytest from fastapi.testclient import TestClient from main import app from unittest.mock import patch, AsyncMock from services.exceptions import CalendarServiceError +from tests.auth_helpers import auth_headers -client = TestClient(app, headers={"X-User-Id": "testuser"}) +client = TestClient(app, headers=auth_headers("testuser")) +def test_calendar_sync_rejects_missing_authentication(): + unauthenticated_client = TestClient(app) + + response = unauthenticated_client.post( + "/api/calendar/sync", + json={"todos": ["Test todo"], "user_token": {"token": "attacker-supplied"}}, + ) + + assert response.status_code == 401 + + +@patch("api.calendar.get_calendar_credentials_for_user") @patch("api.calendar.create_calendar_event", new_callable=AsyncMock) -def test_calendar_sync_endpoint_success(mock_create): +def test_calendar_sync_endpoint_success(mock_create, mock_get_credentials): # Setup mock + mock_get_credentials.return_value = {"token": "server-side-token"} mock_create.return_value = {"id": "123", "summary": "Test todo"} response = client.post( "/api/calendar/sync", - json={"todos": ["Test todo"], "user_token": {"token": "dummy"}}, + json={"todos": ["Test todo"]}, ) assert response.status_code == 200 @@ -22,16 +35,39 @@ def test_calendar_sync_endpoint_success(mock_create): "synced": 1, "events": [{"id": "123", "summary": "Test todo"}], } - mock_create.assert_called_once_with("Test todo", {"token": "dummy"}) + mock_get_credentials.assert_called_once() + called_token = mock_create.call_args.args[1] + assert called_token == {"token": "server-side-token"} + + +@patch("api.calendar.get_calendar_credentials_for_user") +@patch("api.calendar.create_calendar_event", new_callable=AsyncMock) +def test_calendar_sync_rejects_body_tokens_when_server_credentials_missing( + mock_create, mock_get_credentials +): + mock_get_credentials.return_value = None + + response = client.post( + "/api/calendar/sync", + json={"todos": ["Test todo"], "user_token": {"token": "attacker-supplied"}}, + ) + + assert response.status_code == 503 + assert response.json() == { + "detail": "Calendar credentials are not configured for this user" + } + mock_create.assert_not_called() @patch("api.calendar.create_calendar_event", new_callable=AsyncMock) -def test_calendar_sync_endpoint_error(mock_create): +@patch("api.calendar.get_calendar_credentials_for_user") +def test_calendar_sync_endpoint_error(mock_get_credentials, mock_create): + mock_get_credentials.return_value = {"token": "server-side-token"} mock_create.side_effect = CalendarServiceError("Mocked error") response = client.post( "/api/calendar/sync", - json={"todos": ["Test todo"], "user_token": {"token": "dummy"}}, + json={"todos": ["Test todo"]}, ) assert response.status_code == 500 diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py index 488ce11ae..7228f9ef5 100644 --- a/backend/tests/test_db.py +++ b/backend/tests/test_db.py @@ -2,7 +2,6 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from core.config import settings -from db.models import Base @pytest.mark.asyncio diff --git a/backend/tests/test_email_client_smtp.py b/backend/tests/test_email_client_smtp.py index d6ea189a2..1ffec3455 100644 --- a/backend/tests/test_email_client_smtp.py +++ b/backend/tests/test_email_client_smtp.py @@ -1,6 +1,12 @@ +import socket +from unittest.mock import AsyncMock, patch + import pytest + +from services import email_client from services.email_client import send_email + @pytest.mark.asyncio async def test_send_email_raises_error_when_smtp_fails(): with pytest.raises(Exception, match="Failed to send email"): @@ -10,5 +16,91 @@ async def test_send_email_raises_error_when_smtp_fails(): body="Should fail because SMTP server is invalid", smtp_server="invalid.example.com", smtp_port=587, - smtp_username="testuser" + smtp_username="testuser", ) + + +@pytest.mark.asyncio +async def test_send_email_revalidates_smtp_host_resolution_before_connecting( + monkeypatch, +): + def fake_getaddrinfo(host, port, *args, **kwargs): + assert host == "smtp.rebind.example.com" + assert port == 587 + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("10.0.0.5", port), + ) + ] + + monkeypatch.setattr( + "services.mail_server_security.socket.getaddrinfo", fake_getaddrinfo + ) + + with patch( + "services.email_client.aiosmtplib.send", new_callable=AsyncMock + ) as smtp_send: + with pytest.raises(Exception, match="내부 네트워크"): + await send_email( + to_address="test@example.com", + subject="Test Failure", + body="Should fail before SMTP connection", + smtp_server="smtp.rebind.example.com", + smtp_port=587, + smtp_username="testuser", + ) + + smtp_send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_email_uses_validated_smtp_connect_ip(monkeypatch): + def fake_getaddrinfo(host, port, *args, **kwargs): + assert host == "smtp.public.example.com" + assert port == 587 + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", port), + ) + ] + + calls = [] + + async def fake_send(message, target, username, password): + calls.append( + (target.host, target.connect_host, target.port, username, password) + ) + + monkeypatch.setattr( + "services.mail_server_security.socket.getaddrinfo", fake_getaddrinfo + ) + monkeypatch.setattr( + email_client, "_send_message_via_validated_smtp", fake_send, raising=False + ) + + with patch( + "services.email_client.aiosmtplib.send", new_callable=AsyncMock + ) as smtp_send: + result = await email_client.send_email( + to_address="test@example.com", + subject="Pinned IP", + body="Should connect to the validated IP only", + smtp_server="smtp.public.example.com", + smtp_port=587, + smtp_username="testuser", + smtp_password="secret", + ) + + assert result == {"status": "sent", "simulated": False} + assert calls == [ + ("smtp.public.example.com", "93.184.216.34", 587, "testuser", "secret") + ] + smtp_send.assert_not_awaited() diff --git a/backend/tests/test_email_parser.py b/backend/tests/test_email_parser.py index e4c2b8ccc..e9548b465 100644 --- a/backend/tests/test_email_parser.py +++ b/backend/tests/test_email_parser.py @@ -50,7 +50,33 @@ def test_parse_eml_multipart_html_fallback(): try: parsed = parse_eml(temp_path) - assert "

This is HTML content

" in parsed["body"] + assert "This is HTML content" in parsed["body"] + assert "

" not in parsed["body"] + finally: + os.unlink(temp_path) + + +def test_parse_eml_html_fallback_removes_executable_markup(): + eml_content = b"""Message-ID: +From: xss@test.com +To: recipient@test.com +Subject: HTML XSS +Date: Mon, 27 Apr 2026 10:00:00 +0000 +Content-Type: text/html; charset="utf-8" + +

Safe text

+""" + + with tempfile.NamedTemporaryFile(delete=False, suffix=".eml") as f: + f.write(eml_content) + temp_path = f.name + + try: + parsed = parse_eml(temp_path) + assert "Safe text" in parsed["body"] + assert " diff --git a/backend/tests/test_emails_api.py b/backend/tests/test_emails_api.py index 54754e168..92282fa71 100644 --- a/backend/tests/test_emails_api.py +++ b/backend/tests/test_emails_api.py @@ -5,12 +5,15 @@ from main import app import datetime from unittest.mock import patch +from tests.auth_helpers import auth_headers @pytest_asyncio.fixture async def client(): async with AsyncClient( - transport=ASGITransport(app=app), headers={"X-User-Id": "testuser"}, base_url="http://test" + transport=ASGITransport(app=app), + headers=auth_headers("testuser"), + base_url="http://test", ) as ac: yield ac @@ -22,17 +25,24 @@ def __init__(self): self.smtp_username = "testuser" self.smtp_password = None + _DEFAULT_TENANT_CONFIG = object() class MockSession: - def __init__(self, items, tenant_config=_DEFAULT_TENANT_CONFIG): + def __init__( + self, + items, + tenant_config=_DEFAULT_TENANT_CONFIG, + mailbox_account_ids=None, + ): self.items = items self.tenant_config = ( MockTenantConfig() if tenant_config is _DEFAULT_TENANT_CONFIG else tenant_config ) + self.mailbox_account_ids = set(mailbox_account_ids or {1, 2, 3}) async def execute(self, query): class MockResult: @@ -48,9 +58,44 @@ def all(self): def scalar_one_or_none(self): return self.rows[0] if self.rows else None - return MockResult(self.items) + params = query.compile().params + rows = list(self.items) + user_id = next( + (value for key, value in params.items() if "user_id" in key), None + ) + mailbox_account_id = next( + (value for key, value in params.items() if "mailbox_account_id" in key), + None, + ) + item_id = next( + (value for key, value in params.items() if key.startswith("id_")), None + ) + if user_id is not None: + rows = [row for row in rows if getattr(row, "user_id", None) == user_id] + if mailbox_account_id is not None: + rows = [ + row + for row in rows + if getattr(row, "mailbox_account_id", None) + in {None, mailbox_account_id} + ] + if item_id is not None: + rows = [row for row in rows if row.id == item_id] + return MockResult(rows) async def scalar(self, query): + query_text = str(query).lower() + if "from mailbox_accounts" in query_text: + params = query.compile().params + mailbox_account_id = next( + (value for key, value in params.items() if "id" in key), + None, + ) + return ( + mailbox_account_id + if mailbox_account_id in self.mailbox_account_ids + else None + ) return self.tenant_config @@ -73,10 +118,28 @@ def all(self): def scalar_one_or_none(self): return self.rows[0] if self.rows else None + params = query.compile().params + user_id = next( + (value for key, value in params.items() if "user_id" in key), None + ) + mailbox_account_id = next( + (value for key, value in params.items() if "mailbox_account_id" in key), + None, + ) + rows = list(self.items) + if user_id is not None: + rows = [row for row in rows if getattr(row, "user_id", None) == user_id] + if mailbox_account_id is not None: + rows = [ + row + for row in rows + if getattr(row, "mailbox_account_id", None) + in {None, mailbox_account_id} + ] limit_clause = getattr(query, "_limit_clause", None) limit_value = getattr(limit_clause, "value", None) self.last_limit_value = limit_value - rows = self.items[:limit_value] if limit_value else self.items + rows = rows[:limit_value] if limit_value else rows return MockResult(rows) @@ -84,6 +147,7 @@ def scalar_one_or_none(self): def sample_email(): return Email( id=1, + user_id="testuser", message_id="msg123", thread_id="thread123", sender="test@example.com", @@ -112,9 +176,27 @@ def override_get_db(db_session): @pytest.mark.asyncio async def test_get_emails(client: AsyncClient, db_session): + db_session.items[0].mailbox_account_id = 1 response = await client.get("/api/emails?limit=10") assert response.status_code == 200 assert "emails" in response.json() + assert response.json()["emails"][0]["mailbox_account_id"] == 1 + + +@pytest.mark.asyncio +async def test_get_emails_sanitizes_legacy_html_snippet( + client: AsyncClient, db_session, sample_email: Email +): + sample_email.body = '

Safe list text

' + + response = await client.get("/api/emails?limit=10") + + assert response.status_code == 200 + snippet = response.json()["emails"][0]["snippet"] + assert "Safe list text" in snippet + assert "Safe text

' + ) + + response = await client.get(f"/api/emails/{sample_email.id}") + + assert response.status_code == 200 + body = response.json()["body"] + assert "Safe text" in body + assert "\n\nBody") + + previous_owner = settings.LEGACY_EMAIL_OWNER_USER_ID + settings.LEGACY_EMAIL_OWNER_USER_ID = None + imported = await import_fixtures.import_eml_file(MockSession(), eml_file) + settings.LEGACY_EMAIL_OWNER_USER_ID = previous_owner + + assert imported is False + + +@pytest.mark.asyncio +async def test_root_importer_assigns_mailbox_account_id_from_matching_account(tmp_path): + class MockResult: + def __init__(self, scalar=None, rows=None): + self._scalar = scalar + self._rows = rows or [] + + def scalar_one_or_none(self): + return self._scalar + + def scalars(self): + return self + + def all(self): + return self._rows + + class MailboxAccount: + def __init__(self): + self.id = 5 + self.email_address = "beta@example.com" + self.smtp_username = "beta@example.com" + self.imap_username = "beta@example.com" + self.is_default_reply = True + self.is_active = True + + class MockSession: + def __init__(self): + self.added = None + + async def execute(self, query): + query_str = str(query).lower() + if "from mailbox_accounts" in query_str: + return MockResult(rows=[MailboxAccount()]) + return MockResult() + + def add(self, obj): + self.added = obj + + async def commit(self): + pass + + eml_file = tmp_path / "matching-mailbox.eml" + eml_file.write_text("Message-ID: \n\nBody") + parsed = { + "message_id": "", + "sender": "outside@example.com", + "recipients": "beta@example.com", + "subject": "Matching mailbox", + "date": datetime.datetime.now(datetime.timezone.utc), + "body": "Body", + "attachments": [], + } + session = MockSession() + previous_owner = settings.LEGACY_EMAIL_OWNER_USER_ID + settings.LEGACY_EMAIL_OWNER_USER_ID = "testuser" + + with patch.object(import_fixtures, "parse_eml", return_value=parsed), patch.object( + import_fixtures, "generate_embeddings", new_callable=AsyncMock + ) as mock_embeddings, patch.object( + import_fixtures, "assign_thread_id", new_callable=AsyncMock + ) as mock_assign: + mock_embeddings.return_value = [[0.0] * 1536] + mock_assign.return_value = "matching-thread" + + imported = await import_fixtures.import_eml_file(session, eml_file) + + settings.LEGACY_EMAIL_OWNER_USER_ID = previous_owner + + assert imported is True + assert session.added is not None + assert session.added.mailbox_account_id == 5 + + +@pytest.mark.asyncio +async def test_root_importer_upgrades_legacy_null_mailbox_row_when_match_is_found( + tmp_path, +): + class MockResult: + def __init__(self, scalar=None, rows=None): + self._scalar = scalar + self._rows = rows or [] + + def scalar_one_or_none(self): + return self._scalar + + def scalars(self): + return self + + def all(self): + return self._rows + + class MailboxAccount: + def __init__(self): + self.id = 5 + self.email_address = "beta@example.com" + self.smtp_username = "beta@example.com" + self.imap_username = "beta@example.com" + self.is_default_reply = True + self.is_active = True + + @dataclass + class LegacyEmail: + mailbox_account_id: int | None = None + sender: str = "outside@example.com" + reply_to: str | None = None + recipients: str = "beta@example.com" + subject: str = "Matching mailbox" + in_reply_to: str | None = None + references: str | None = None + date: datetime.datetime = datetime.datetime.now(datetime.timezone.utc) + body: str = "Old body" + embedding: list[float] | None = None + thread_id: str = "legacy-thread" + + legacy_email = LegacyEmail() + + class MockSession: + def __init__(self): + self.commit_count = 0 + + async def execute(self, query): + query_str = str(query).lower() + if "from mailbox_accounts" in query_str: + return MockResult(rows=[MailboxAccount()]) + if "mailbox_account_id is null" in query_str: + return MockResult(scalar=legacy_email) + return MockResult() + + async def commit(self): + self.commit_count += 1 + + eml_file = tmp_path / "upgrade-legacy.eml" + eml_file.write_text("Message-ID: \n\nBody") + parsed = { + "message_id": "", + "sender": "outside@example.com", + "recipients": "beta@example.com", + "subject": "Matching mailbox", + "date": datetime.datetime.now(datetime.timezone.utc), + "body": "Body", + "attachments": [], + } + session = MockSession() + previous_owner = settings.LEGACY_EMAIL_OWNER_USER_ID + settings.LEGACY_EMAIL_OWNER_USER_ID = "testuser" + + with patch.object(import_fixtures, "parse_eml", return_value=parsed), patch.object( + import_fixtures, "generate_embeddings", new_callable=AsyncMock + ) as mock_embeddings, patch.object( + import_fixtures, "assign_thread_id", new_callable=AsyncMock + ) as mock_assign: + mock_embeddings.return_value = [[0.0] * 1536] + mock_assign.return_value = "matching-thread" + imported = await import_fixtures.import_eml_file(session, eml_file) + + settings.LEGACY_EMAIL_OWNER_USER_ID = previous_owner + + assert imported is True + assert legacy_email.mailbox_account_id == 5 + assert legacy_email.thread_id == "matching-thread" + assert session.commit_count == 1 diff --git a/backend/tests/test_llm_api.py b/backend/tests/test_llm_api.py index 15332b65b..94e7714cb 100644 --- a/backend/tests/test_llm_api.py +++ b/backend/tests/test_llm_api.py @@ -3,24 +3,30 @@ from fastapi.testclient import TestClient from main import app from db.session import get_db +from tests.auth_helpers import auth_headers + class MockTenantConfig: def __init__(self): self.openai_api_key = "test-key" + class MockSession: async def scalar(self, stmt): return MockTenantConfig() + @pytest.fixture def client(): async def override_get_db(): yield MockSession() + app.dependency_overrides[get_db] = override_get_db - with TestClient(app, headers={"X-User-Id": "testuser"}) as c: + with TestClient(app, headers=auth_headers("testuser")) as c: yield c app.dependency_overrides.clear() + @patch("api.llm.extract_todos_and_summary", new_callable=AsyncMock) @patch("api.llm.draft_reply", new_callable=AsyncMock) def test_llm_endpoints_exist(mock_draft, mock_extract, client): @@ -39,6 +45,7 @@ def test_llm_endpoints_exist(mock_draft, mock_extract, client): assert resp1.status_code == 200 assert resp2.status_code == 200 + @patch("api.llm.extract_todos_and_summary", new_callable=AsyncMock) def test_summarize_endpoint(mock_extract, client): from services.llm_service import ExtractionResult @@ -49,7 +56,12 @@ def test_summarize_endpoint(mock_extract, client): resp = client.post("/api/llm/summarize", json={"email_body": "test email"}) assert resp.status_code == 200 - assert resp.json() == {"summary": "Test summary", "todos": ["Task 1"], "provenance": None} + assert resp.json() == { + "summary": "Test summary", + "todos": ["Task 1"], + "provenance": None, + } + @patch("api.llm.draft_reply", new_callable=AsyncMock) def test_draft_endpoint(mock_draft, client): @@ -61,4 +73,3 @@ def test_draft_endpoint(mock_draft, client): ) assert resp.status_code == 200 assert resp.json() == {"draft": "This is a draft reply."} - diff --git a/backend/tests/test_llm_providers_api.py b/backend/tests/test_llm_providers_api.py index 89af1abc6..2768354e7 100644 --- a/backend/tests/test_llm_providers_api.py +++ b/backend/tests/test_llm_providers_api.py @@ -1,7 +1,13 @@ import datetime +import base64 +import hashlib +import hmac +import json import pytest from fastapi.testclient import TestClient +from sqlalchemy import UniqueConstraint +from sqlalchemy.exc import IntegrityError from core.config import settings from db.models import AuditLog, LLMProvider @@ -32,12 +38,29 @@ class MockSession: def __init__(self): self.providers: list[LLMProvider] = [] self.audits: list[AuditLog] = [] + self.commit_error: Exception | None = None async def execute(self, stmt): stmt_str = str(stmt).lower() + params = stmt.compile().params + providers = list(self.providers) + organization_id = next( + (value for key, value in params.items() if "organization_id" in key), None + ) + if organization_id is not None: + providers = [ + provider + for provider in providers + if getattr(provider, "organization_id", None) == organization_id + ] if "llm_providers.id =" in stmt_str: - return MockResult(self.providers[:1]) - return MockResult(self.providers) + provider_id = next( + (value for key, value in params.items() if key.startswith("id_")), None + ) + providers = [ + provider for provider in providers if provider.id == provider_id + ] + return MockResult(providers) def add(self, obj): if isinstance(obj, LLMProvider): @@ -48,11 +71,20 @@ def add(self, obj): self.audits.append(obj) async def delete(self, obj): - self.providers = [provider for provider in self.providers if provider is not obj] + self.providers = [ + provider for provider in self.providers if provider is not obj + ] async def commit(self): + if self.commit_error is not None: + error = self.commit_error + self.commit_error = None + raise error return None + async def rollback(self): + self.commit_error = None + async def refresh(self, obj): return None @@ -60,12 +92,61 @@ async def refresh(self, obj): mock_session = MockSession() +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def _encode_test_jwt(payload: dict, secret: str) -> str: + header = {"alg": "HS256", "typ": "JWT"} + header_part = _b64url(json.dumps(header, separators=(",", ":")).encode()) + payload_part = _b64url(json.dumps(payload, separators=(",", ":")).encode()) + signing_input = f"{header_part}.{payload_part}".encode() + signature = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest() + return f"{header_part}.{payload_part}.{_b64url(signature)}" + + +def _auth_headers( + user_id: str, role: str, organization_id: str | None = "org-acme" +) -> dict[str, str]: + secret = settings.OIDC_SHARED_SECRET + assert secret is not None + token = _encode_test_jwt( + { + "sub": user_id, + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "roles": [role], + **({"organization_id": organization_id} if organization_id else {}), + }, + secret, + ) + return {"Authorization": f"Bearer {token}"} + + @pytest.fixture(autouse=True) -def enable_dev_headers(): - previous = settings.TRUST_DEV_HEADERS - settings.TRUST_DEV_HEADERS = True +def configure_oidc_auth(): + previous_auth_mode = settings.AUTH_MODE + previous_secret = settings.OIDC_SHARED_SECRET + previous_issuer = settings.OIDC_ISSUER + previous_audience = settings.OIDC_AUDIENCE + previous_trust = settings.TRUST_DEV_HEADERS + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" + settings.TRUST_DEV_HEADERS = False yield - settings.TRUST_DEV_HEADERS = previous + settings.AUTH_MODE = previous_auth_mode + settings.OIDC_SHARED_SECRET = previous_secret + settings.OIDC_ISSUER = previous_issuer + settings.OIDC_AUDIENCE = previous_audience + settings.TRUST_DEV_HEADERS = previous_trust @pytest.fixture(autouse=True) @@ -84,11 +165,7 @@ async def override_db(): def admin_client(): with TestClient( app, - headers={ - "X-User-Id": "admin", - "X-User-Role": "organization_admin", - "X-Organization-Id": "org-acme", - }, + headers=_auth_headers("admin", "organization_admin"), ) as client: yield client @@ -97,11 +174,27 @@ def admin_client(): def member_client(): with TestClient( app, - headers={ - "X-User-Id": "member", - "X-User-Role": "member", - "X-Organization-Id": "org-acme", - }, + headers=_auth_headers("member", "member"), + ) as client: + yield client + + +@pytest.fixture +def org_admin_without_scope_client(): + with TestClient( + app, + headers=_auth_headers("org-admin", "organization_admin", organization_id=None), + ) as client: + yield client + + +@pytest.fixture +def second_org_admin_client(): + with TestClient( + app, + headers=_auth_headers( + "beta-admin", "organization_admin", organization_id="org-beta" + ), ) as client: yield client @@ -121,6 +214,7 @@ def test_llm_provider_crud_admin(admin_client): assert data["configured"] is True assert data["fingerprint"] is not None assert "api_key" not in data + assert mock_session.providers[0].organization_id == "org-acme" provider_id = data["id"] @@ -128,7 +222,9 @@ def test_llm_provider_crud_admin(admin_client): assert response.status_code == 200 assert len(response.json()) == 1 - response = admin_client.put(f"/api/llm-providers/{provider_id}", json={"is_active": True}) + response = admin_client.put( + f"/api/llm-providers/{provider_id}", json={"is_active": True} + ) assert response.status_code == 200 assert response.json()["is_active"] is True @@ -138,10 +234,104 @@ def test_llm_provider_crud_admin(admin_client): def test_llm_provider_member_rejected(member_client): response = member_client.get("/api/llm-providers") - assert response.status_code == 200 + assert response.status_code == 403 response = member_client.post( "/api/llm-providers", json={"name": "Malicious", "provider_type": "openai"}, ) assert response.status_code == 403 + + +def test_llm_provider_org_admin_without_scope_is_rejected( + org_admin_without_scope_client, +): + response = org_admin_without_scope_client.get("/api/llm-providers") + assert response.status_code == 403 + + response = org_admin_without_scope_client.post( + "/api/llm-providers", + json={"name": "Scoped", "provider_type": "openai"}, + ) + assert response.status_code == 403 + + +def test_llm_provider_list_is_scoped_to_requesting_organization( + admin_client, second_org_admin_client +): + mock_session.providers = [ + LLMProvider( + id=1, + name="Acme Provider", + provider_type="openai", + api_key="sk-acme", + is_active=True, + organization_id="org-acme", + updated_at=datetime.datetime.now(datetime.timezone.utc), + ), + LLMProvider( + id=2, + name="Beta Provider", + provider_type="anthropic", + api_key="sk-beta", + is_active=True, + organization_id="org-beta", + updated_at=datetime.datetime.now(datetime.timezone.utc), + ), + ] + + acme_response = admin_client.get("/api/llm-providers") + assert acme_response.status_code == 200 + assert [provider["name"] for provider in acme_response.json()] == ["Acme Provider"] + + beta_response = second_org_admin_client.get("/api/llm-providers") + assert beta_response.status_code == 200 + assert [provider["name"] for provider in beta_response.json()] == ["Beta Provider"] + + +def test_llm_provider_name_uniqueness_is_scoped_per_organization(): + unique_constraints = [ + constraint + for constraint in LLMProvider.__table__.constraints + if isinstance(constraint, UniqueConstraint) + ] + + assert any( + tuple(column.name for column in constraint.columns) + == ("organization_id", "name") + for constraint in unique_constraints + ) + assert not any( + tuple(column.name for column in constraint.columns) == ("name",) + for constraint in unique_constraints + ) + + +def test_llm_provider_update_duplicate_name_returns_conflict(admin_client): + mock_session.providers = [ + LLMProvider( + id=1, + name="Primary OpenAI", + provider_type="openai", + api_key="sk-primary", + is_active=True, + organization_id="org-acme", + updated_at=datetime.datetime.now(datetime.timezone.utc), + ), + LLMProvider( + id=2, + name="Backup OpenAI", + provider_type="openai", + api_key="sk-backup", + is_active=True, + organization_id="org-acme", + updated_at=datetime.datetime.now(datetime.timezone.utc), + ), + ] + mock_session.commit_error = IntegrityError( + "duplicate", params=None, orig=Exception("duplicate") + ) + + response = admin_client.put("/api/llm-providers/2", json={"name": "Primary OpenAI"}) + + assert response.status_code == 409 diff --git a/backend/tests/test_mailbox_accounts_api.py b/backend/tests/test_mailbox_accounts_api.py new file mode 100644 index 000000000..2439907be --- /dev/null +++ b/backend/tests/test_mailbox_accounts_api.py @@ -0,0 +1,460 @@ +import datetime +import socket + +import pytest +from fastapi.testclient import TestClient + +from db.session import get_db +from main import app +from tests.auth_helpers import auth_headers + +PRIVATE_HOST_DETAIL = "메일 서버 주소는 내부 네트워크를 사용할 수 없습니다." +UNSAFE_SMTP_PORT_DETAIL = "SMTP 포트는 허용된 메일 포트만 사용할 수 있습니다." + + +class MockResult: + def __init__(self, rows): + self.rows = rows + + def scalars(self): + return self + + def all(self): + return self.rows + + def scalar_one_or_none(self): + return self.rows[0] if self.rows else None + + +class MockMailboxAccount: + def __init__( + self, + *, + id: int, + user_id: str, + email_address: str, + display_name: str | None, + provider: str, + is_default_reply: bool, + is_active: bool, + smtp_server: str | None = None, + smtp_port: int | None = None, + smtp_username: str | None = None, + smtp_password: str | None = None, + imap_server: str | None = None, + imap_port: int | None = None, + imap_username: str | None = None, + imap_password: str | None = None, + pop3_server: str | None = None, + pop3_port: int | None = None, + pop3_username: str | None = None, + pop3_password: str | None = None, + updated_at: datetime.datetime | None = None, + ): + self.id = id + self.user_id = user_id + self.email_address = email_address + self.display_name = display_name + self.provider = provider + self.is_default_reply = is_default_reply + self.is_active = is_active + self.smtp_server = smtp_server + self.smtp_port = smtp_port + self.smtp_username = smtp_username + self.smtp_password = smtp_password + self.imap_server = imap_server + self.imap_port = imap_port + self.imap_username = imap_username + self.imap_password = imap_password + self.pop3_server = pop3_server + self.pop3_port = pop3_port + self.pop3_username = pop3_username + self.pop3_password = pop3_password + self.created_at = updated_at or datetime.datetime.now(datetime.timezone.utc) + self.updated_at = updated_at or datetime.datetime.now(datetime.timezone.utc) + + +class MockSession: + def __init__(self, accounts=None): + self.accounts = accounts or [] + self.next_id = max((item.id for item in self.accounts), default=0) + 1 + self.commit_error: Exception | None = None + + async def execute(self, query): + params = query.compile().params + query_str = str(query).lower() + rows = list(self.accounts) + if "from mailbox_accounts" in query_str: + user_id = next( + (value for key, value in params.items() if "user_id" in key), None + ) + account_id = next( + (value for key, value in params.items() if key.startswith("id_")), None + ) + email_address = next( + (value for key, value in params.items() if "email_address" in key), None + ) + if user_id is not None: + rows = [row for row in rows if row.user_id == user_id] + if account_id is not None: + rows = [row for row in rows if row.id == account_id] + if email_address is not None: + rows = [row for row in rows if row.email_address == email_address] + rows = sorted(rows, key=lambda row: row.updated_at, reverse=True) + else: + rows = [] + return MockResult(rows) + + def add(self, obj): + if getattr(obj, "id", None) is None: + obj.id = self.next_id + self.next_id += 1 + self.accounts.append(obj) + + async def commit(self): + if self.commit_error is not None: + error = self.commit_error + self.commit_error = None + raise error + return None + + async def refresh(self, obj): + return None + + async def rollback(self): + return None + + +@pytest.fixture +def mock_db(): + return MockSession( + [ + MockMailboxAccount( + id=1, + user_id="testuser", + email_address="alpha@example.com", + display_name="Alpha", + provider="custom", + is_default_reply=True, + is_active=True, + smtp_server="smtp.example.com", + smtp_port=587, + smtp_username="alpha@example.com", + smtp_password="smtp-secret", + imap_server="imap.example.com", + imap_port=993, + imap_username="alpha@example.com", + imap_password="imap-secret", + pop3_server="pop.example.com", + pop3_port=995, + pop3_username="alpha@example.com", + pop3_password="pop-secret", + ), + MockMailboxAccount( + id=2, + user_id="other-user", + email_address="other@example.com", + display_name="Other", + provider="custom", + is_default_reply=True, + is_active=True, + ), + ] + ) + + +@pytest.fixture +def client(mock_db): + async def override_get_db(): + yield mock_db + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app, headers=auth_headers("testuser")) as c: + yield c + app.dependency_overrides.clear() + + +def test_mailbox_accounts_are_listed_for_current_user_only(client): + response = client.get("/api/mailbox-accounts") + + assert response.status_code == 200 + data = response.json()["items"] + assert len(data) == 1 + assert data[0]["email_address"] == "alpha@example.com" + assert data[0]["smtp_password_set"] is True + assert data[0]["imap_password_set"] is True + assert data[0]["pop3_server"] == "pop.example.com" + assert data[0]["pop3_password_set"] is True + + +def test_mailbox_account_create_adds_new_account_and_demotes_existing_default( + client, mock_db +): + response = client.post( + "/api/mailbox-accounts", + json={ + "email_address": "beta@example.com", + "display_name": "Beta", + "provider": "custom", + "is_default_reply": True, + "smtp_server": "smtp.beta.example.com", + "smtp_port": 587, + "smtp_username": "beta@example.com", + "smtp_password": "smtp-beta", + "imap_server": "imap.beta.example.com", + "imap_port": 993, + "imap_username": "beta@example.com", + "imap_password": "imap-beta", + "pop3_server": "pop.beta.example.com", + "pop3_port": 995, + "pop3_username": "beta@example.com", + "pop3_password": "pop-beta", + }, + ) + + assert response.status_code == 200 + data = response.json()["item"] + assert data["email_address"] == "beta@example.com" + assert data["pop3_server"] == "pop.beta.example.com" + assert data["pop3_password_set"] is True + current_user_accounts = [ + item for item in mock_db.accounts if item.user_id == "testuser" + ] + assert len(current_user_accounts) == 2 + assert [item.is_default_reply for item in current_user_accounts] == [False, True] + + +def test_mailbox_account_create_forces_default_reply_accounts_active(client): + response = client.post( + "/api/mailbox-accounts", + json={ + "email_address": "gamma@example.com", + "display_name": "Gamma", + "provider": "custom", + "is_default_reply": True, + "is_active": False, + }, + ) + + assert response.status_code == 200 + assert response.json()["item"]["is_default_reply"] is True + assert response.json()["item"]["is_active"] is True + + +def test_mailbox_account_patch_is_self_owned(client, mock_db): + response = client.patch( + "/api/mailbox-accounts/1", + json={"display_name": "Alpha Updated", "is_active": False}, + ) + + assert response.status_code == 200 + assert response.json()["item"]["display_name"] == "Alpha Updated" + assert mock_db.accounts[0].is_active is False + + +def test_mailbox_account_patch_updates_email_address_for_owner(client, mock_db): + response = client.patch( + "/api/mailbox-accounts/1", json={"email_address": "renamed@example.com"} + ) + + assert response.status_code == 200 + assert response.json()["item"]["email_address"] == "renamed@example.com" + assert mock_db.accounts[0].email_address == "renamed@example.com" + + +def test_mailbox_account_patch_forces_default_reply_accounts_active(client, mock_db): + mock_db.accounts[0].is_default_reply = False + mock_db.accounts[0].is_active = False + + response = client.patch( + "/api/mailbox-accounts/1", json={"is_default_reply": True, "is_active": False} + ) + + assert response.status_code == 200 + assert response.json()["item"]["is_default_reply"] is True + assert response.json()["item"]["is_active"] is True + + +def test_mailbox_account_create_sanitizes_nul_characters(client): + response = client.post( + "/api/mailbox-accounts", + json={ + "email_address": "nul@example.com", + "display_name": "Bad\u0000Name", + "provider": "custom", + "smtp_username": "nul\u0000user@example.com", + "smtp_password": "pass\u0000word", + }, + ) + + assert response.status_code == 200 + data = response.json()["item"] + assert data["display_name"] == "BadName" + assert data["smtp_username"] == "nuluser@example.com" + + +def test_mailbox_account_create_normalizes_email_and_rejects_partial_pop3_config( + client, +): + response = client.post( + "/api/mailbox-accounts", + json={ + "email_address": " padded@example.com ", + "display_name": " Padded ", + "provider": "custom", + "pop3_server": "pop.example.com", + }, + ) + + assert response.status_code == 400 + assert "POP3 서버와 포트는 함께 설정해야 합니다." in response.json()["detail"] + + +@pytest.mark.parametrize( + ("field", "host", "port"), + [ + ("smtp", "127.0.0.1", 587), + ("smtp", "100.64.0.1", 587), + ("imap", "localhost", 993), + ("pop3", "169.254.169.254", 995), + ], +) +def test_mailbox_account_create_rejects_internal_mail_server_hosts( + client, + field, + host, + port, +): + response = client.post( + "/api/mailbox-accounts", + json={ + "email_address": f"{field}@example.com", + "display_name": field.upper(), + "provider": "custom", + f"{field}_server": host, + f"{field}_port": port, + }, + ) + + assert response.status_code == 400 + assert PRIVATE_HOST_DETAIL in response.json()["detail"] + + +def test_mailbox_account_create_rejects_hosts_that_resolve_to_private_ips( + client, + monkeypatch, +): + def fake_getaddrinfo(host, port, *args, **kwargs): + assert host == "mail.internal.example.com" + assert port == 993 + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("10.0.0.5", port), + ) + ] + + monkeypatch.setattr( + "services.mail_server_security.socket.getaddrinfo", fake_getaddrinfo + ) + + response = client.post( + "/api/mailbox-accounts", + json={ + "email_address": "resolved-private@example.com", + "display_name": "Resolved private", + "provider": "custom", + "imap_server": "mail.internal.example.com", + "imap_port": 993, + }, + ) + + assert response.status_code == 400 + assert PRIVATE_HOST_DETAIL in response.json()["detail"] + + +def test_mailbox_account_create_rejects_unsafe_mail_server_ports(client): + response = client.post( + "/api/mailbox-accounts", + json={ + "email_address": "ssh@example.com", + "display_name": "SSH", + "provider": "custom", + "smtp_server": "smtp.example.com", + "smtp_port": 22, + }, + ) + + assert response.status_code == 400 + assert UNSAFE_SMTP_PORT_DETAIL in response.json()["detail"] + + +def test_mailbox_account_create_returns_conflict_for_duplicate_email_address( + client, mock_db +): + from sqlalchemy.exc import IntegrityError + + mock_db.commit_error = IntegrityError( + "duplicate", params=None, orig=Exception("duplicate") + ) + + response = client.post( + "/api/mailbox-accounts", + json={ + "email_address": "alpha@example.com", + "display_name": "Alpha 2", + "provider": "custom", + }, + ) + + assert response.status_code == 409 + + +def test_mailbox_account_create_returns_503_when_encryption_key_is_missing( + client, mock_db +): + mock_db.commit_error = Exception("ENCRYPTION_KEY is required to encrypt secrets") + + response = client.post( + "/api/mailbox-accounts", + json={ + "email_address": "secure@example.com", + "display_name": "Secure", + "provider": "custom", + "smtp_password": "secret", + }, + ) + + assert response.status_code == 503 + assert "Server encryption key is not configured" in response.json()["detail"] + + +def test_mailbox_account_make_default_is_self_owned(client, mock_db): + mock_db.accounts.append( + MockMailboxAccount( + id=3, + user_id="testuser", + email_address="beta@example.com", + display_name="Beta", + provider="custom", + is_default_reply=False, + is_active=True, + ) + ) + + response = client.post("/api/mailbox-accounts/3/make-default-reply") + + assert response.status_code == 200 + assert mock_db.accounts[0].is_default_reply is False + assert mock_db.accounts[-1].is_default_reply is True + + +def test_mailbox_account_endpoints_reject_cross_user_access(client): + response = client.patch("/api/mailbox-accounts/2", json={"display_name": "Nope"}) + assert response.status_code == 404 + + response = client.post("/api/mailbox-accounts/2/make-default-reply") + assert response.status_code == 404 diff --git a/backend/tests/test_mailbox_routing.py b/backend/tests/test_mailbox_routing.py new file mode 100644 index 000000000..d3d453fc8 --- /dev/null +++ b/backend/tests/test_mailbox_routing.py @@ -0,0 +1,80 @@ +from db.models import MailboxAccount +from services.mailbox_routing import resolve_mailbox_account_id_for_email + + +def test_resolve_mailbox_account_matches_recipient_mailbox(): + accounts = [ + MailboxAccount( + id=1, + user_id="testuser", + email_address="alpha@example.com", + display_name="Alpha", + provider="custom", + is_default_reply=False, + is_active=True, + ), + MailboxAccount( + id=2, + user_id="testuser", + email_address="beta@example.com", + display_name="Beta", + provider="custom", + is_default_reply=True, + is_active=True, + ), + ] + + resolved = resolve_mailbox_account_id_for_email( + accounts, + sender="outside@example.com", + reply_to=None, + recipients="beta@example.com", + ) + + assert resolved == 2 + + +def test_resolve_mailbox_account_matches_sender_for_sent_mail(): + accounts = [ + MailboxAccount( + id=3, + user_id="testuser", + email_address="owner@example.com", + display_name="Owner", + provider="custom", + is_default_reply=True, + is_active=True, + ) + ] + + resolved = resolve_mailbox_account_id_for_email( + accounts, + sender="owner@example.com", + reply_to=None, + recipients="partner@example.com", + ) + + assert resolved == 3 + + +def test_resolve_mailbox_account_falls_back_to_single_active_account_when_no_header_match(): + accounts = [ + MailboxAccount( + id=4, + user_id="testuser", + email_address="solo@example.com", + display_name="Solo", + provider="custom", + is_default_reply=False, + is_active=True, + ) + ] + + resolved = resolve_mailbox_account_id_for_email( + accounts, + sender="outside@example.com", + reply_to=None, + recipients="unknown@example.com", + ) + + assert resolved == 4 diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index b59f926f4..ce748de45 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -1,7 +1,8 @@ from fastapi.testclient import TestClient from main import app +from tests.auth_helpers import auth_headers -client = TestClient(app, headers={"X-User-Id": "testuser"}) +client = TestClient(app, headers=auth_headers("testuser")) def test_read_root(): diff --git a/backend/tests/test_network_api.py b/backend/tests/test_network_api.py index 439ec6e39..752ab2be0 100644 --- a/backend/tests/test_network_api.py +++ b/backend/tests/test_network_api.py @@ -1,10 +1,10 @@ -import pytest from fastapi.testclient import TestClient from main import app from db.session import get_db from unittest.mock import patch +from tests.auth_helpers import auth_headers -client = TestClient(app, headers={"X-User-Id": "testuser"}) +client = TestClient(app, headers=auth_headers("testuser")) class MockResult: @@ -18,18 +18,37 @@ def fetchall(self): class MockSession: def __init__(self, rows): self.rows = rows + self.queries = [] async def execute(self, query): + self.queries.append(query) return MockResult(self.rows) def get_override(rows): + session = MockSession(rows) + async def override_get_db(): - yield MockSession(rows) + yield session + override_get_db.session = session return override_get_db +def test_network_graph_filters_email_rows_to_current_user(): + override = get_override( + [ + ("alice@example.com", "bob@example.com"), + ] + ) + with patch.dict(app.dependency_overrides, {get_db: override}): + response = client.get("/api/network/graph") + + assert response.status_code == 200 + compiled_query = str(override.session.queries[0]) + assert "emails.user_id" in compiled_query.lower() + + def test_network_endpoint_exists(): with patch.dict( app.dependency_overrides, @@ -127,7 +146,9 @@ def test_network_endpoint_query_params(): ) }, ): - response = client.get("/api/network/graph?limit=10&user_id=123", headers={"X-User-Id": "123"}) + response = client.get( + "/api/network/graph?limit=10&user_id=123", headers=auth_headers("123") + ) assert response.status_code == 200 data = response.json() assert len(data["nodes"]) == 2 diff --git a/backend/tests/test_pop3_worker_sync.py b/backend/tests/test_pop3_worker_sync.py new file mode 100644 index 000000000..d4c93893d --- /dev/null +++ b/backend/tests/test_pop3_worker_sync.py @@ -0,0 +1,273 @@ +import socket +import ssl +import pytest +from unittest.mock import AsyncMock, patch + +from db.models import MailboxAccount +from services import pop3_worker +from services.mail_server_security import MailServerConnectTarget +from services.pop3_worker import Pop3SyncWorker + + +class MockResult: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return self + + def all(self): + return self._rows + + +class MockSession: + def __init__(self, accounts): + self.accounts = accounts + + async def execute(self, _query): + return MockResult(self.accounts) + + +class MockSessionContext: + def __init__(self, session): + self.session = session + + async def __aenter__(self): + return self.session + + async def __aexit__(self, exc_type, exc, tb): + return False + + +@pytest.mark.asyncio +async def test_pop3_worker_sync_mailbox_account_raises_when_invalid_server(): + worker = Pop3SyncWorker() + account = MailboxAccount( + user_id="testuser", + email_address="alpha@example.com", + display_name="Alpha", + provider="custom", + is_default_reply=True, + is_active=True, + pop3_server="invalid.example.com", + pop3_port=995, + ) + + with pytest.raises( + Exception, match="POP3 Sync failed for user testuser mailbox alpha@example.com" + ): + await worker._sync_mailbox_account(account, AsyncMock()) + + +@pytest.mark.asyncio +async def test_pop3_worker_sync_enumerates_active_mailbox_accounts_with_pop3_server( + monkeypatch, +): + worker = Pop3SyncWorker() + accounts = [ + MailboxAccount( + id=1, + user_id="testuser", + email_address="alpha@example.com", + display_name="Alpha", + provider="custom", + is_default_reply=True, + is_active=True, + pop3_server="pop.alpha.example.com", + pop3_port=995, + ), + MailboxAccount( + id=2, + user_id="testuser", + email_address="beta@example.com", + display_name="Beta", + provider="custom", + is_default_reply=False, + is_active=False, + pop3_server="pop.beta.example.com", + pop3_port=995, + ), + MailboxAccount( + id=3, + user_id="testuser", + email_address="gamma@example.com", + display_name="Gamma", + provider="custom", + is_default_reply=False, + is_active=True, + pop3_server=None, + pop3_port=995, + ), + ] + + monkeypatch.setattr( + "services.pop3_worker.AsyncSessionLocal", + lambda: MockSessionContext(MockSession(accounts)), + ) + sync_mock = AsyncMock(return_value=None) + monkeypatch.setattr(worker, "_sync_mailbox_account", sync_mock) + + await worker._sync() + + sync_mock.assert_awaited_once() + assert sync_mock.await_args_list[0].args[0].email_address == "alpha@example.com" + + +def test_pop3_sync_uses_bounded_timeout(monkeypatch): + def fake_getaddrinfo(host, port, *args, **kwargs): + assert host == "pop.alpha.example.com" + assert port == 995 + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", port), + ) + ] + + class FakePop3Client: + def quit(self): + return None + + calls = [] + + def fake_create_client(target, timeout): + calls.append((target.host, target.connect_host, target.port, timeout)) + return FakePop3Client() + + monkeypatch.setattr( + "services.mail_server_security.socket.getaddrinfo", fake_getaddrinfo + ) + monkeypatch.setattr( + "services.pop3_worker._create_pop3_ssl_client", fake_create_client + ) + + worker = Pop3SyncWorker() + account = MailboxAccount( + user_id="testuser", + email_address="alpha@example.com", + display_name="Alpha", + provider="custom", + is_default_reply=True, + is_active=True, + pop3_server="pop.alpha.example.com", + pop3_port=995, + ) + + worker._do_pop3_sync(account) + + assert calls == [("pop.alpha.example.com", "93.184.216.34", 995, 15)] + + +def test_pop3_sync_uses_validated_connect_ip(monkeypatch): + def fake_getaddrinfo(host, port, *args, **kwargs): + assert host == "pop.public.example.com" + assert port == 995 + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", port), + ) + ] + + class FakePop3Client: + def quit(self): + return None + + calls = [] + + def fake_create_client(target, timeout): + calls.append((target.host, target.connect_host, target.port, timeout)) + return FakePop3Client() + + monkeypatch.setattr( + "services.mail_server_security.socket.getaddrinfo", fake_getaddrinfo + ) + monkeypatch.setattr( + "services.pop3_worker._create_pop3_ssl_client", + fake_create_client, + raising=False, + ) + + worker = Pop3SyncWorker() + account = MailboxAccount( + user_id="testuser", + email_address="alpha@example.com", + display_name="Alpha", + provider="custom", + is_default_reply=True, + is_active=True, + pop3_server="pop.public.example.com", + pop3_port=995, + ) + + with patch("services.pop3_worker.poplib.POP3_SSL") as pop3_ssl: + worker._do_pop3_sync(account) + + assert calls == [("pop.public.example.com", "93.184.216.34", 995, 15)] + pop3_ssl.assert_not_called() + + +def test_pop3_pinned_client_uses_verifying_tls_context(monkeypatch): + calls = [] + + class FakePinnedPOP3: + def __init__(self, host, port, connect_host, timeout, context): + calls.append((host, port, connect_host, timeout, context)) + + monkeypatch.setattr(pop3_worker, "_PinnedPOP3_SSL", FakePinnedPOP3) + + pop3_worker._create_pop3_ssl_client( + MailServerConnectTarget( + host="pop.public.example.com", + port=995, + connect_host="93.184.216.34", + ), + timeout=15, + ) + + context = calls[0][4] + assert context.verify_mode == ssl.CERT_REQUIRED + assert context.check_hostname is True + + +def test_pop3_sync_revalidates_host_resolution_before_connecting(monkeypatch): + def fake_getaddrinfo(host, port, *args, **kwargs): + assert host == "pop.rebind.example.com" + assert port == 995 + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("10.0.0.5", port), + ) + ] + + monkeypatch.setattr( + "services.mail_server_security.socket.getaddrinfo", fake_getaddrinfo + ) + + worker = Pop3SyncWorker() + account = MailboxAccount( + user_id="testuser", + email_address="alpha@example.com", + display_name="Alpha", + provider="custom", + is_default_reply=True, + is_active=True, + pop3_server="pop.rebind.example.com", + pop3_port=995, + ) + + with patch("services.pop3_worker.poplib.POP3_SSL") as pop3_ssl: + with pytest.raises(Exception, match="내부 네트워크"): + worker._do_pop3_sync(account) + + pop3_ssl.assert_not_called() diff --git a/backend/tests/test_prompts_api.py b/backend/tests/test_prompts_api.py index ea5a53769..b6ace3490 100644 --- a/backend/tests/test_prompts_api.py +++ b/backend/tests/test_prompts_api.py @@ -1,44 +1,57 @@ +from typing import cast + import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession from main import app -from db.models import PromptTemplate +from db.models import LLMProvider, PromptTemplate, TenantConfig from db.session import get_db +from api.auth import AuthContext +from tests.auth_helpers import auth_headers + class MockSession: def __init__(self): self.items = [] - + async def execute(self, stmt): class MockResult: def scalars(self): class MockScalars: def all(self): return self.items + def first(self): return self.items[0] if self.items else None + m = MockScalars() - m.items = getattr(self, 'items', self.parent_items) + m.items = getattr(self, "items", self.parent_items) return m + res = MockResult() res.parent_items = self.items return res - + def add(self, obj): import datetime + if isinstance(obj, PromptTemplate): obj.id = len(self.items) + 1 obj.created_at = datetime.datetime.now(datetime.timezone.utc) obj.updated_at = datetime.datetime.now(datetime.timezone.utc) self.items.append(obj) - + async def commit(self): pass - + async def refresh(self, obj): pass + mock_session = MockSession() + @pytest.fixture(autouse=True) def override_get_db(): app.dependency_overrides[get_db] = lambda: mock_session @@ -46,46 +59,357 @@ def override_get_db(): app.dependency_overrides.clear() mock_session.items = [] + @pytest.fixture def auth_client(): - with TestClient(app, headers={"X-User-Id": "testuser"}) as c: + with TestClient(app, headers=auth_headers("testuser")) as c: yield c + def test_prompt_crud(auth_client): # Create - resp = auth_client.post("/api/prompts", json={ - "title": "Test Prompt", - "description": "A test prompt", - "content": "Summarize this: {{email}}", - "is_shared": False - }) + resp = auth_client.post( + "/api/prompts", + json={ + "title": "Test Prompt", + "description": "A test prompt", + "content": "Summarize this: {{email}}", + "is_shared": False, + }, + ) assert resp.status_code == 200, resp.text data = resp.json() assert data["title"] == "Test Prompt" - + # List resp = auth_client.get("/api/prompts") assert resp.status_code == 200 assert len(resp.json()) == 1 + def test_prompt_test_execution_mocked(auth_client, monkeypatch): import api.prompts as prompts_module - + # Mock LLM service async def mock_execute(*args, **kwargs): return {"result": "Mocked LLM result"} - + monkeypatch.setattr(prompts_module, "execute_prompt_with_llm", mock_execute) - + # Mock the db to have a provider from db.models import LLMProvider - p = LLMProvider(id=1, name="Test", provider_type="openai", api_key="test-key", is_active=True) + + p = LLMProvider( + id=1, + organization_id="org-current", + name="Test", + provider_type="openai", + api_key="test-key", + is_active=True, + ) mock_session.items.append(p) - - resp = auth_client.post("/api/prompts/test", json={ - "content": "Summarize this: {{email}}", - "variables": {"email": "hello world"} - }) - + + resp = auth_client.post( + "/api/prompts/test", + json={ + "content": "Summarize this: {{email}}", + "variables": {"email": "hello world"}, + }, + headers=auth_headers( + "admin", role="organization_admin", organization_id="org-current" + ), + ) + assert resp.status_code == 200 assert resp.json()["result"] == "Mocked LLM result" + + +def test_prompt_test_removes_nul_bytes_before_execution(auth_client, monkeypatch): + import api.prompts as prompts_module + + captured_prompt = None + + async def mock_execute(prompt_text, *args, **kwargs): + nonlocal captured_prompt + captured_prompt = prompt_text + return {"result": "Mocked LLM result"} + + monkeypatch.setattr(prompts_module, "execute_prompt_with_llm", mock_execute) + + p = LLMProvider( + id=1, + organization_id="org-current", + name="Test", + provider_type="openai", + api_key="test-key", + is_active=True, + ) + mock_session.items.append(p) + + resp = auth_client.post( + "/api/prompts/test", + json={ + "content": "Nu\u0000l prompt: {{email}}", + "variables": {"email": "he\u0000llo"}, + }, + headers=auth_headers( + "admin", role="organization_admin", organization_id="org-current" + ), + ) + + assert resp.status_code == 200 + assert captured_prompt == "Nul prompt: hello" + + +class QueryAwarePromptSession: + def __init__(self): + self.other_org_provider = LLMProvider( + id=1, + organization_id="org-other", + name="Other Org", + provider_type="openai", + api_key="other-org-key", + is_active=True, + ) + self.user_tenant_config = TenantConfig( + user_id="testuser", openai_api_key="legacy-user-key" + ) + + async def execute(self, stmt): + statement = str(stmt).lower() + if "llm_providers" in statement: + if "llm_providers.organization_id" in statement: + return _ListResult([]) + return _ListResult([self.other_org_provider]) + if "tenant_configs" in statement: + return _ListResult([self.user_tenant_config]) + return _ListResult([]) + + +class _ListResult: + def __init__(self, items): + self.items = items + + def scalars(self): + return self + + def first(self): + return self.items[0] if self.items else None + + def all(self): + return self.items + + +def test_prompt_test_fails_closed_without_active_provider_for_current_org(monkeypatch): + import api.prompts as prompts_module + + called = False + + async def mock_execute(*args, **kwargs): + nonlocal called + called = True + return {"result": "should not execute"} + + monkeypatch.setattr(prompts_module, "execute_prompt_with_llm", mock_execute) + app.dependency_overrides[get_db] = lambda: QueryAwarePromptSession() + + with TestClient( + app, + headers=auth_headers( + "admin", role="organization_admin", organization_id="org-current" + ), + ) as c: + resp = c.post( + "/api/prompts/test", + json={ + "content": "Summarize this: {{email}}", + "variables": {"email": "hello world"}, + }, + ) + + assert resp.status_code == 400 + assert resp.json() == {"detail": "LLM API key not configured"} + assert called is False + + +def test_prompt_list_statement_scopes_shared_prompts_to_current_org(): + from api.prompts import build_prompt_list_statement + + auth_context = AuthContext( + user_id="testuser", + role="member", + organization_id="org-current", + group_ids=(), + workspace_id="workspace-org-current", + ) + + statement = build_prompt_list_statement(auth_context) + sql = str(statement).lower() + params = statement.compile().params + + assert "prompt_templates.organization_id" in sql + assert "prompt_templates.is_shared" in sql + assert "created_by" in sql + assert "org-current" in params.values() + + +def test_prompt_create_rejects_shared_prompt_without_org_scope(auth_client): + response = auth_client.post( + "/api/prompts", + json={ + "title": "Shared Prompt", + "content": "Summarize this: {{email}}", + "is_shared": True, + }, + ) + + assert response.status_code == 400 + assert response.json() == {"detail": "Shared prompts require organization scope"} + + +def test_prompt_create_rejects_shared_prompt_for_non_admin_member(auth_client): + response = auth_client.post( + "/api/prompts", + json={ + "title": "Shared Prompt", + "content": "Summarize this: {{email}}", + "is_shared": True, + }, + headers=auth_headers("member-1", role="member", organization_id="org-current"), + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Workspace admin role is required for shared prompts" + } + + +def test_prompt_create_allows_shared_prompt_for_organization_admin(auth_client): + response = auth_client.post( + "/api/prompts", + json={ + "title": "Shared Prompt", + "description": "Available to the org", + "content": "Summarize this: {{email}}", + "is_shared": True, + }, + headers=auth_headers( + "admin-1", role="organization_admin", organization_id="org-current" + ), + ) + + assert response.status_code == 200, response.text + assert response.json()["is_shared"] is True + assert response.json()["organization_id"] == "org-current" + assert response.json()["created_by"] == "admin-1" + + +def test_prompt_create_removes_nul_bytes_before_persistence_and_response(auth_client): + response = auth_client.post( + "/api/prompts", + json={ + "title": "Nu\u0000l Title", + "description": "Nu\u0000l Description", + "content": "Nu\u0000l Content {{email}}", + "is_shared": False, + }, + ) + + assert response.status_code == 200, response.text + assert response.json()["title"] == "Nul Title" + assert response.json()["description"] == "Nul Description" + assert response.json()["content"] == "Nul Content {{email}}" + assert mock_session.items[0].title == "Nul Title" + assert mock_session.items[0].description == "Nul Description" + assert mock_session.items[0].content == "Nul Content {{email}}" + + +def test_prompt_test_rejects_non_admin_member_before_provider_lookup( + auth_client, monkeypatch +): + import api.prompts as prompts_module + + called = False + + async def mock_execute(*args, **kwargs): + nonlocal called + called = True + return {"result": "should not execute"} + + monkeypatch.setattr(prompts_module, "execute_prompt_with_llm", mock_execute) + + response = auth_client.post( + "/api/prompts/test", + json={ + "content": "Summarize this: {{email}}", + "variables": {"email": "hello world"}, + }, + headers=auth_headers("member-1", role="member", organization_id="org-current"), + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Workspace admin role is required for prompt testing" + } + assert called is False + + +def test_prompt_test_rejects_non_admin_member_without_org_before_provider_lookup( + auth_client, monkeypatch +): + import api.prompts as prompts_module + + called = False + + async def mock_execute(*args, **kwargs): + nonlocal called + called = True + return {"result": "should not execute"} + + monkeypatch.setattr(prompts_module, "execute_prompt_with_llm", mock_execute) + + response = auth_client.post( + "/api/prompts/test", + json={ + "content": "Summarize this: {{email}}", + "variables": {"email": "hello world"}, + }, + headers=auth_headers("member-1", role="member"), + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Workspace admin role is required for prompt testing" + } + assert called is False + + +@pytest.mark.asyncio +async def test_prompt_test_rejects_non_admin_member_before_variable_access(): + import api.prompts as prompts_module + + class VariablesThatMustNotBeAccessed(dict): + def items(self): + raise AssertionError("variables accessed before authorization") + + class PromptTestPayload: + content = "Summarize this: {{email}}" + variables = VariablesThatMustNotBeAccessed({"email": "hello world"}) + + with pytest.raises(HTTPException) as exc_info: + await prompts_module.test_prompt( + cast(prompts_module.PromptTestRequest, PromptTestPayload()), + db=cast(AsyncSession, mock_session), + auth_context=AuthContext( + user_id="member-1", + role="member", + organization_id="org-current", + group_ids=(), + workspace_id="workspace-org-current", + ), + ) + + assert exc_info.value.status_code == 403 + assert ( + exc_info.value.detail == "Workspace admin role is required for prompt testing" + ) diff --git a/backend/tests/test_repo_hygiene.py b/backend/tests/test_repo_hygiene.py index 1e1660886..2a746ee05 100644 --- a/backend/tests/test_repo_hygiene.py +++ b/backend/tests/test_repo_hygiene.py @@ -1,6 +1,5 @@ from pathlib import Path - REPO_ROOT = Path(__file__).resolve().parents[2] @@ -31,6 +30,33 @@ def test_env_example_documents_required_postgres_password(): assert "postgres:change-me-local-only@" in env_example +def test_local_compose_defaults_to_fail_closed_auth_for_dev_only_stacks(): + compose = (REPO_ROOT / "docker-compose.yml").read_text() + live_e2e_compose = (REPO_ROOT / "docker-compose.live-e2e.yml").read_text() + + assert "AUTH_MODE: ${AUTH_MODE:-hybrid}" in compose + assert "TRUST_DEV_HEADERS: ${TRUST_DEV_HEADERS:-false}" in compose + assert "AUTH_MODE: ${AUTH_MODE:-header}" not in compose + assert "TRUST_DEV_HEADERS: ${TRUST_DEV_HEADERS:-true}" not in compose + + assert "AUTH_MODE: ${AUTH_MODE:-hybrid}" in live_e2e_compose + assert "TRUST_DEV_HEADERS: ${TRUST_DEV_HEADERS:-false}" in live_e2e_compose + assert "AUTH_MODE: ${AUTH_MODE:-header}" not in live_e2e_compose + assert "TRUST_DEV_HEADERS: ${TRUST_DEV_HEADERS:-true}" not in live_e2e_compose + assert "127.0.0.1:8000:8000" in compose + assert "127.0.0.1:3000:3000" in compose + assert "127.0.0.1:18080:8080" in live_e2e_compose + + +def test_env_example_defaults_to_fail_closed_auth_mode(): + env_example = (REPO_ROOT / ".env.example").read_text() + + assert "AUTH_MODE=hybrid" in env_example + assert "TRUST_DEV_HEADERS=false" in env_example + assert "LEGACY_EMAIL_OWNER_USER_ID=" in env_example + assert "LEGACY_EMAIL_OWNER_USER_ID=testuser" not in env_example + + def test_readme_uses_cross_platform_browser_command(): readme = (REPO_ROOT / "README.md").read_text() @@ -39,3 +65,14 @@ def test_readme_uses_cross_platform_browser_command(): "python -m webbrowser http://localhost:3000" in readme or "python3 -m webbrowser http://localhost:3000" in readme ) + + +def test_placeholder_routes_avoid_bracket_paths_for_base_strix(): + """Keep placeholders aggregate-only until protected Strix supports brackets.""" + + bracket_routes = [ + REPO_ROOT / "frontend/src/app/labels/[slug]/page.tsx", + REPO_ROOT / "frontend/src/app/projects/[slug]/page.tsx", + ] + + assert not [path for path in bracket_routes if path.exists()] diff --git a/backend/tests/test_runner_config_api.py b/backend/tests/test_runner_config_api.py index 657ddb05e..ff36f8f99 100644 --- a/backend/tests/test_runner_config_api.py +++ b/backend/tests/test_runner_config_api.py @@ -1,3 +1,9 @@ +import base64 +import datetime +import hashlib +import hmac +import json + from fastapi.testclient import TestClient import pytest @@ -40,11 +46,60 @@ def mock_db(): @pytest.fixture(autouse=True) -def enable_dev_headers(): - previous = settings.TRUST_DEV_HEADERS - settings.TRUST_DEV_HEADERS = True +def configure_oidc_auth(): + previous_auth_mode = settings.AUTH_MODE + previous_secret = settings.OIDC_SHARED_SECRET + previous_issuer = settings.OIDC_ISSUER + previous_audience = settings.OIDC_AUDIENCE + previous_trust = settings.TRUST_DEV_HEADERS + settings.AUTH_MODE = "oidc" + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_ISSUER = "https://issuer.example.com/realms/naruon" + settings.OIDC_AUDIENCE = "naruon-web" + settings.TRUST_DEV_HEADERS = False yield - settings.TRUST_DEV_HEADERS = previous + settings.AUTH_MODE = previous_auth_mode + settings.OIDC_SHARED_SECRET = previous_secret + settings.OIDC_ISSUER = previous_issuer + settings.OIDC_AUDIENCE = previous_audience + settings.TRUST_DEV_HEADERS = previous_trust + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def _encode_test_jwt(payload: dict, secret: str) -> str: + header = {"alg": "HS256", "typ": "JWT"} + header_part = _b64url(json.dumps(header, separators=(",", ":")).encode()) + payload_part = _b64url(json.dumps(payload, separators=(",", ":")).encode()) + signing_input = f"{header_part}.{payload_part}".encode() + signature = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest() + return f"{header_part}.{payload_part}.{_b64url(signature)}" + + +def _auth_headers( + user_id: str, role: str, organization_id: str | None = "org-acme" +) -> dict[str, str]: + secret = settings.OIDC_SHARED_SECRET + assert secret is not None + token = _encode_test_jwt( + { + "sub": user_id, + "iss": settings.OIDC_ISSUER, + "aud": settings.OIDC_AUDIENCE, + "exp": int( + ( + datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=5) + ).timestamp() + ), + "roles": [role], + **({"organization_id": organization_id} if organization_id else {}), + }, + secret, + ) + return {"Authorization": f"Bearer {token}"} @pytest.fixture @@ -54,7 +109,7 @@ async def override_get_db(): app.dependency_overrides[get_db] = override_get_db try: - with TestClient(app, headers={"X-User-Id": "testuser", "X-Organization-Id": "org-acme"}) as c: + with TestClient(app, headers=_auth_headers("testuser", "member")) as c: yield c finally: app.dependency_overrides.pop(get_db, None) @@ -69,11 +124,7 @@ async def override_get_db(): try: with TestClient( app, - headers={ - "X-User-Id": "admin", - "X-User-Role": "organization_admin", - "X-Organization-Id": "org-acme", - }, + headers=_auth_headers("admin", "organization_admin"), ) as c: yield c finally: @@ -89,11 +140,7 @@ async def override_get_db(): try: with TestClient( app, - headers={ - "X-User-Id": "org-admin-2", - "X-User-Role": "organization_admin", - "X-Organization-Id": "org-acme", - }, + headers=_auth_headers("org-admin-2", "organization_admin"), ) as c: yield c finally: @@ -109,11 +156,7 @@ async def override_get_db(): try: with TestClient( app, - headers={ - "X-User-Id": "platform-root", - "X-User-Role": "platform_admin", - "X-Organization-Id": "org-acme", - }, + headers=_auth_headers("platform-root", "platform_admin"), ) as c: yield c finally: @@ -128,7 +171,9 @@ def test_member_cannot_manage_runner_config(member_client): assert response.status_code == 403 -def test_org_scoped_runner_config_uses_shared_workspace(admin_client, second_org_admin_client, mock_db): +def test_org_scoped_runner_config_uses_shared_workspace( + admin_client, second_org_admin_client, mock_db +): rotate_response = admin_client.post("/api/runner-config/rotate") assert rotate_response.status_code == 200 rotate_data = rotate_response.json() @@ -159,10 +204,7 @@ async def override_get_db(): try: with TestClient( app, - headers={ - "X-User-Id": "admin", - "X-User-Role": "organization_admin", - }, + headers=_auth_headers("admin", "organization_admin", organization_id=None), ) as client: response = client.get("/api/runner-config") assert response.status_code == 403 diff --git a/backend/tests/test_runtime_config_api.py b/backend/tests/test_runtime_config_api.py index b99992279..1d61f80e5 100644 --- a/backend/tests/test_runtime_config_api.py +++ b/backend/tests/test_runtime_config_api.py @@ -1,13 +1,18 @@ import pytest from fastapi.testclient import TestClient + +from core.config import settings from main import app +from tests.auth_helpers import auth_headers + @pytest.fixture def client(): # Pass default headers for authentication - with TestClient(app, headers={"X-User-Id": "testuser"}) as c: + with TestClient(app, headers=auth_headers("testuser")) as c: yield c + def test_runtime_config_returns_non_secret_data(client): response = client.get("/api/runtime-config") assert response.status_code == 200 @@ -17,3 +22,53 @@ def test_runtime_config_returns_non_secret_data(client): # Ensure no secrets leak assert "openai_api_key" not in data assert "encryption_key" not in data + + +def test_runtime_config_does_not_advertise_dev_header_auth(client): + previous_auth_mode = settings.AUTH_MODE + previous_trust = settings.TRUST_DEV_HEADERS + previous_secret = settings.OIDC_SHARED_SECRET + previous_jwks = settings.OIDC_JWKS_URL + settings.AUTH_MODE = "header" + settings.TRUST_DEV_HEADERS = True + settings.OIDC_SHARED_SECRET = None + settings.OIDC_JWKS_URL = None + + response = client.get("/api/runtime-config") + + settings.AUTH_MODE = previous_auth_mode + settings.TRUST_DEV_HEADERS = previous_trust + settings.OIDC_SHARED_SECRET = previous_secret + settings.OIDC_JWKS_URL = previous_jwks + + assert response.status_code == 200 + data = response.json() + assert data["features"]["dev_header_auth_enabled"] is False + assert data["features"]["manual_bearer_login_enabled"] is False + + +def test_runtime_config_requires_complete_oidc_inputs_for_manual_bearer(client): + previous_auth_mode = settings.AUTH_MODE + previous_trust = settings.TRUST_DEV_HEADERS + previous_secret = settings.OIDC_SHARED_SECRET + previous_jwks = settings.OIDC_JWKS_URL + previous_issuer = settings.OIDC_ISSUER + previous_audience = settings.OIDC_AUDIENCE + settings.AUTH_MODE = "oidc" + settings.TRUST_DEV_HEADERS = False + settings.OIDC_SHARED_SECRET = "test-secret" + settings.OIDC_JWKS_URL = None + settings.OIDC_ISSUER = None + settings.OIDC_AUDIENCE = None + + response = client.get("/api/runtime-config") + + settings.AUTH_MODE = previous_auth_mode + settings.TRUST_DEV_HEADERS = previous_trust + settings.OIDC_SHARED_SECRET = previous_secret + settings.OIDC_JWKS_URL = previous_jwks + settings.OIDC_ISSUER = previous_issuer + settings.OIDC_AUDIENCE = previous_audience + + assert response.status_code == 200 + assert response.json()["features"]["manual_bearer_login_enabled"] is False diff --git a/backend/tests/test_search.py b/backend/tests/test_search.py index 62e5a870b..bc6d5eb47 100644 --- a/backend/tests/test_search.py +++ b/backend/tests/test_search.py @@ -4,11 +4,13 @@ from fastapi.testclient import TestClient from main import app from db.session import get_db +from tests.auth_helpers import auth_headers class MockRow: def __init__(self, id, subject, sender, content, score): self.id = id + self.mailbox_account_id = 2 self.subject = subject self.sender = sender self.content = content @@ -27,14 +29,42 @@ class MockTenantConfig: def __init__(self): self.openai_api_key = "test-key" + class MockSession: + def __init__(self, mailbox_account_ids=None): + self.mailbox_account_ids = set(mailbox_account_ids or {1, 2, 3}) + async def execute(self, stmt): return MockResult() - + async def scalar(self, stmt): + query_text = str(stmt).lower() + if "from mailbox_accounts" in query_text: + params = stmt.compile().params + mailbox_account_id = next( + (value for key, value in params.items() if "id" in key), + None, + ) + return ( + mailbox_account_id + if mailbox_account_id in self.mailbox_account_ids + else None + ) return MockTenantConfig() +class MailboxScopedMockSession(MockSession): + async def execute(self, stmt): + params = stmt.compile().params + mailbox_account_id = next( + (value for key, value in params.items() if "mailbox_account_id" in key), + None, + ) + if mailbox_account_id == 2: + return MockResult() + return type("EmptyResult", (), {"all": lambda self: []})() + + async def override_get_db(): yield MockSession() @@ -42,7 +72,7 @@ async def override_get_db(): @pytest.fixture def client(): app.dependency_overrides[get_db] = override_get_db - with TestClient(app, headers={"X-User-Id": "testuser"}) as c: + with TestClient(app, headers=auth_headers("testuser")) as c: yield c app.dependency_overrides.clear() @@ -64,15 +94,64 @@ def test_search_endpoint_success(mock_generate_embeddings, client): assert len(data["results"]) == 1 assert data["results"][0]["id"] == 1 assert data["results"][0]["subject"] == "Test Subject" + assert data["results"][0]["mailbox_account_id"] == 2 assert data["results"][0]["date"] == "2026-04-27T10:00:00Z" assert data["results"][0]["thread_id"] == "thread-123" assert data["results"][0]["reply_count"] == 2 +@patch("api.search.generate_embeddings", new_callable=AsyncMock) +def test_search_endpoint_filters_by_mailbox_account_id(mock_generate_embeddings): + async def scoped_db(): + yield MailboxScopedMockSession() + + app.dependency_overrides[get_db] = scoped_db + mock_generate_embeddings.return_value = [[0.1] * 1536] + + try: + with TestClient(app, headers=auth_headers("testuser")) as client: + allowed = client.post( + "/api/search", json={"query": "test query", "mailbox_account_id": 2} + ) + blocked = client.post( + "/api/search", json={"query": "test query", "mailbox_account_id": 1} + ) + finally: + app.dependency_overrides.clear() + + assert allowed.status_code == 200 + assert len(allowed.json()["results"]) == 1 + assert blocked.status_code == 200 + assert blocked.json()["results"] == [] + + +@patch("api.search.generate_embeddings", new_callable=AsyncMock) +def test_search_endpoint_rejects_unowned_mailbox_account_id( + mock_generate_embeddings, +): + async def scoped_db(): + yield MockSession(mailbox_account_ids={2}) + + app.dependency_overrides[get_db] = scoped_db + mock_generate_embeddings.return_value = [[0.1] * 1536] + + try: + with TestClient(app, headers=auth_headers("testuser")) as client: + response = client.post( + "/api/search", json={"query": "test query", "mailbox_account_id": 1} + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 404 + assert response.json() == {"detail": "Mailbox account not found"} + mock_generate_embeddings.assert_not_called() + + def test_search_reply_counts_group_by_coalesced_thread_key(): from api.search import build_reply_counts_subquery - subquery = build_reply_counts_subquery() + subquery = build_reply_counts_subquery("testuser") sql = str(subquery.select()).lower() assert "coalesce(nullif(btrim(btrim(emails.thread_id)" in sql @@ -80,3 +159,13 @@ def test_search_reply_counts_group_by_coalesced_thread_key(): assert "coalesce(emails.thread_id, emails.message_id)" not in sql assert "count(emails.id)" in sql assert "group by coalesce(nullif(btrim(btrim(emails.thread_id)" in sql + + +def test_mailbox_scoped_search_keeps_legacy_null_mailbox_thread_context(): + from api.search import build_reply_counts_subquery + + subquery = build_reply_counts_subquery("testuser", mailbox_account_id=2) + sql = str(subquery.select()).lower() + + assert "emails.mailbox_account_id" in sql + assert "emails.mailbox_account_id is null" in sql diff --git a/backend/tests/test_tenant_config_api.py b/backend/tests/test_tenant_config_api.py index ab06c67bf..40944f04c 100644 --- a/backend/tests/test_tenant_config_api.py +++ b/backend/tests/test_tenant_config_api.py @@ -2,6 +2,15 @@ from fastapi.testclient import TestClient from main import app from db.session import get_db +from tests.auth_helpers import auth_headers + +MANAGE_OWN_SETTINGS_DETAIL = ( + "Mailbox settings are personal and can only be managed by the authenticated user" +) +VIEW_OWN_SETTINGS_DETAIL = ( + "Mailbox settings are personal and can only be viewed by the authenticated user" +) + # Mock async DB session class MockResult: @@ -11,6 +20,7 @@ def __init__(self, obj): def scalar_one_or_none(self): return self.obj + class MockAsyncSession: def __init__(self): self.objects = {} @@ -24,20 +34,23 @@ def add(self, obj): async def commit(self): pass + @pytest.fixture def mock_db(): return MockAsyncSession() + @pytest.fixture def client(mock_db): async def override_get_db(): yield mock_db - + app.dependency_overrides[get_db] = override_get_db - with TestClient(app, headers={"X-User-Id": "testuser"}) as c: + with TestClient(app, headers=auth_headers("testuser")) as c: yield c app.dependency_overrides.clear() + def test_tenant_config_endpoint(client, mock_db): post_payload = { "user_id": "test_user", @@ -51,13 +64,19 @@ def test_tenant_config_endpoint(client, mock_db): "imap_password": "imap-secret", "oauth_client_secret": "secret-456", } - response = client.post("/api/config", json=post_payload, headers={"X-User-Id": "test_user"}) + response = client.post( + "/api/config", json=post_payload, headers=auth_headers("test_user") + ) assert response.status_code == 200 assert response.json() == {"status": "ok"} assert "test_user" in mock_db.objects - get_response = client.get("/api/config", params={"user_id": "test_user"}, headers={"X-User-Id": "test_user"}) + get_response = client.get( + "/api/config", + params={"user_id": "test_user"}, + headers=auth_headers("test_user"), + ) assert get_response.status_code == 200 data = get_response.json() assert data["user_id"] == "test_user" @@ -77,31 +96,23 @@ def test_tenant_config_stays_user_owned_even_for_admin_headers(client): response = client.post( "/api/config", json={"user_id": "member-user", "smtp_server": "smtp.example.com"}, - headers={ - "X-User-Id": "admin", - "X-User-Role": "organization_admin", - "X-Organization-Id": "org-acme", - }, + headers=auth_headers( + "admin", role="organization_admin", organization_id="org-acme" + ), ) assert response.status_code == 403 - assert response.json() == { - "detail": "Mailbox settings are personal and can only be managed by the authenticated user" - } + assert response.json() == {"detail": MANAGE_OWN_SETTINGS_DETAIL} def test_tenant_config_get_rejects_cross_user_access(client): response = client.get( "/api/config", params={"user_id": "member-user"}, - headers={ - "X-User-Id": "admin", - "X-User-Role": "platform_admin", - "X-Organization-Id": "org-acme", - }, + headers=auth_headers( + "admin", role="platform_admin", organization_id="org-acme" + ), ) assert response.status_code == 403 - assert response.json() == { - "detail": "Mailbox settings are personal and can only be viewed by the authenticated user" - } + assert response.json() == {"detail": VIEW_OWN_SETTINGS_DETAIL} diff --git a/backend/tests/test_tenant_config_model.py b/backend/tests/test_tenant_config_model.py index 37d72b3bd..2edab6ab5 100644 --- a/backend/tests/test_tenant_config_model.py +++ b/backend/tests/test_tenant_config_model.py @@ -1,20 +1,24 @@ import pytest +from pydantic import SecretStr from sqlalchemy import create_engine, text from sqlalchemy.orm import Session -from db.models import TenantConfig +from db.models import TenantConfig, get_fernet from core.config import settings TEST_OPENAI_KEY = "test_key2" # noqa: S105 TEST_IMAP_PASSWORD = "imap-secret" # noqa: S105 TEST_SMTP_PASSWORD = "smtp-secret" # noqa: S105 + @pytest.fixture(autouse=True) def mock_debug(): old_debug = settings.DEBUG + old_key = settings.ENCRYPTION_KEY settings.DEBUG = True + settings.ENCRYPTION_KEY = SecretStr("test-encryption-key") yield settings.DEBUG = old_debug - + settings.ENCRYPTION_KEY = old_key @pytest.fixture @@ -76,3 +80,16 @@ def test_tenant_config_model_encryption(db_session): assert "has_openai_key=True" in repr_str assert "has_imap_password=True" in repr_str assert "has_smtp_password=True" in repr_str + + +def test_encryption_key_is_required_even_in_debug(): + old_key = settings.ENCRYPTION_KEY + old_debug = settings.DEBUG + settings.ENCRYPTION_KEY = None + settings.DEBUG = True + try: + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is required"): + get_fernet() + finally: + settings.ENCRYPTION_KEY = old_key + settings.DEBUG = old_debug diff --git a/backend/tests/test_worker_lifecycle.py b/backend/tests/test_worker_lifecycle.py new file mode 100644 index 000000000..b2f08ce40 --- /dev/null +++ b/backend/tests/test_worker_lifecycle.py @@ -0,0 +1,71 @@ +import pytest +from fastapi import FastAPI +from unittest.mock import AsyncMock + +from main import lifespan + + +@pytest.mark.asyncio +async def test_lifespan_starts_and_stops_imap_and_pop3_workers(monkeypatch): + imap_start = AsyncMock() + imap_stop = AsyncMock() + pop3_start = AsyncMock() + pop3_stop = AsyncMock() + + monkeypatch.setattr("main.DISABLE_WORKERS", False) + monkeypatch.setattr("main.imap_worker.start", imap_start) + monkeypatch.setattr("main.imap_worker.stop", imap_stop) + monkeypatch.setattr("main.pop3_worker.start", pop3_start) + monkeypatch.setattr("main.pop3_worker.stop", pop3_stop) + + async with lifespan(FastAPI()): + pass + + imap_start.assert_awaited_once() + pop3_start.assert_awaited_once() + imap_stop.assert_awaited_once() + pop3_stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_lifespan_stops_started_worker_when_pop3_start_fails(monkeypatch): + imap_start = AsyncMock() + imap_stop = AsyncMock() + pop3_start = AsyncMock(side_effect=RuntimeError("pop3 failed")) + pop3_stop = AsyncMock() + + monkeypatch.setattr("main.DISABLE_WORKERS", False) + monkeypatch.setattr("main.imap_worker.start", imap_start) + monkeypatch.setattr("main.imap_worker.stop", imap_stop) + monkeypatch.setattr("main.pop3_worker.start", pop3_start) + monkeypatch.setattr("main.pop3_worker.stop", pop3_stop) + + with pytest.raises(RuntimeError, match="pop3 failed"): + async with lifespan(FastAPI()): + pass + + imap_start.assert_awaited_once() + pop3_start.assert_awaited_once() + imap_stop.assert_awaited_once() + pop3_stop.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_lifespan_stops_workers_when_app_context_raises(monkeypatch): + imap_start = AsyncMock() + imap_stop = AsyncMock() + pop3_start = AsyncMock() + pop3_stop = AsyncMock() + + monkeypatch.setattr("main.DISABLE_WORKERS", False) + monkeypatch.setattr("main.imap_worker.start", imap_start) + monkeypatch.setattr("main.imap_worker.stop", imap_stop) + monkeypatch.setattr("main.pop3_worker.start", pop3_start) + monkeypatch.setattr("main.pop3_worker.stop", pop3_stop) + + with pytest.raises(RuntimeError, match="app failed"): + async with lifespan(FastAPI()): + raise RuntimeError("app failed") + + imap_stop.assert_awaited_once() + pop3_stop.assert_awaited_once() diff --git a/docker-compose.live-e2e.yml b/docker-compose.live-e2e.yml index c5559646e..b26eacc56 100644 --- a/docker-compose.live-e2e.yml +++ b/docker-compose.live-e2e.yml @@ -19,6 +19,7 @@ services: environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD for live E2E}@db:5432/${POSTGRES_DB:-ai_email_live} ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} + AUTH_MODE: ${AUTH_MODE:-hybrid} TRUST_DEV_HEADERS: ${TRUST_DEV_HEADERS:-false} DEBUG: "false" OPENAI_API_KEY: "" @@ -32,6 +33,7 @@ services: environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD for live E2E}@db:5432/${POSTGRES_DB:-ai_email_live} ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} + AUTH_MODE: ${AUTH_MODE:-hybrid} TRUST_DEV_HEADERS: ${TRUST_DEV_HEADERS:-false} DEBUG: "false" OPENAI_API_KEY: "" @@ -45,6 +47,9 @@ services: environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD for live E2E}@db:5432/${POSTGRES_DB:-ai_email_live} ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} + # Live E2E auth remains fail-closed; use signed bearer tokens or an OIDC + # provider/session rather than client identity headers. + AUTH_MODE: ${AUTH_MODE:-hybrid} TRUST_DEV_HEADERS: ${TRUST_DEV_HEADERS:-false} DEBUG: "false" OPENAI_API_KEY: "" @@ -73,7 +78,7 @@ services: - backend - frontend ports: - - "18080:8080" + - "127.0.0.1:18080:8080" volumes: - ./tests/live/nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/docker-compose.yml b/docker-compose.yml index 558e9b23e..1d1d032fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,10 @@ services: dockerfile: Dockerfile environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env before running Docker Compose}@db:5432/${POSTGRES_DB:-ai_email} + # Compose auth defaults stay fail-closed. Local verification uses signed + # bearer tokens or an OIDC provider/session, not client identity headers. + AUTH_MODE: ${AUTH_MODE:-hybrid} + TRUST_DEV_HEADERS: ${TRUST_DEV_HEADERS:-false} DEBUG: "false" OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_EMBEDDING_MODEL: ${OPENAI_EMBEDDING_MODEL:-text-embedding-3-small} @@ -29,7 +33,7 @@ services: db: condition: service_healthy ports: - - "8000:8000" + - "127.0.0.1:8000:8000" command: ["sh", "-c", "python scripts/bootstrap_db.py && uvicorn main:app --host 0.0.0.0 --port 8000"] frontend: @@ -46,7 +50,7 @@ services: depends_on: - backend ports: - - "3000:3000" + - "127.0.0.1:3000:3000" volumes: postgres-data: diff --git a/docs/operations/auth-key-management.md b/docs/operations/auth-key-management.md index 14fbf984a..4463b7a3f 100644 --- a/docs/operations/auth-key-management.md +++ b/docs/operations/auth-key-management.md @@ -2,19 +2,69 @@ ## 확인된 사실 / Confirmed -- `backend/api/auth.py` is dummy header auth using `X-User-Id` and defaults to - `default`. +- `backend/api/auth.py` now authenticates from verified bearer/OIDC claims only. +- Default backend auth is fail-closed (`AUTH_MODE=hybrid`). The legacy + `AUTH_MODE=header` value remains parseable for configuration compatibility but + no longer authenticates client-controlled identity headers. +- `DEBUG=true` and `TRUST_DEV_HEADERS=true` do not make `X-User-*` request + headers authoritative for user, role, organization, group, or workspace scope. +- `docker-compose.yml` and `docker-compose.live-e2e.yml` default to fail-closed + auth (`AUTH_MODE=hybrid`, `TRUST_DEV_HEADERS=false`). Localhost development or + live-E2E runs must use signed bearer tokens or an OIDC provider/session. +- In `oidc`/`hybrid`, bearer tokens are decoded into the shared `AuthContext` + contract and map `sub`, role claims, `organization_id`, and `groups`. +- The backend accepts HS256 shared-secret tokens and RS256 bearer tokens + validated against `OIDC_JWKS_URL`, which matches staged Keycloak/Casdoor + rollout patterns. +- Dev header fallback is removed from the backend request dependency. The + frontend no longer sends `X-User-*` identity headers and the legacy dev identity + switcher renders nothing. +- `/api/runner-config` is organization-scoped and claim-gated to + `platform_admin` / `organization_admin` with organization scope. +- `/api/llm-providers` is claim-gated and now persists `organization_id`, with + `(organization_id, name)` as the uniqueness boundary. +- `/api/prompts` keeps private prompts user-owned, while shared prompt templates + require an authenticated `organization_id` plus `platform_admin` or + `organization_admin`. Shared prompts are listed only within that organization, + and provider-backed `/api/prompts/test` is also workspace-admin-only because it + consumes the organization's LLM provider configuration. Prompt Studio mirrors + that backend boundary by hiding its nav/direct controls from non-admin users. +- `/api/execution-items` is a self-owned personal queue only. It now requires + the source email row to belong to the authenticated user's `Email.user_id` + scope, and the queue itself is isolated by `user_id + workspace_id`. +- `/api/mailbox-accounts` accepts user-owned SMTP/IMAP/POP3 account settings but + rejects loopback, private, link-local, metadata, resolved-private, and + non-mail ports before persistence to prevent mailbox setup from becoming an + internal network probe. +- Legacy `llm_providers.organization_id IS NULL` rows are not auto-rewritten on + startup anymore. Operators must opt into the one-off bootstrap mapping with + `LEGACY_LLM_PROVIDER_ORGANIZATION_ID=` or run an equivalent explicit + migration. - `backend/db/models.py` stores OAuth/OpenAI secret fields through an `EncryptedString` type backed by Fernet. - `backend/db/models.py` also has a fallback Fernet key when `ENCRYPTION_KEY` is not configured, so production key management is not complete. -- Email rows do not yet have a mailbox ownership key, as documented in - `ARCHITECTURE.md`. +- Email rows now have a minimal owner key (`Email.user_id`), but that is only a + bridge toward a real `Mailbox` aggregate and does not yet encode provider / + account provenance. +- Mailbox-filtered search/thread reads temporarily include authenticated-owner + legacy rows with `mailbox_account_id IS NULL`; the frontend labels them as + `이전 복원 메일` during the bridge period instead of presenting them as native + rows from the selected mailbox. +- Legacy rows are only backfilled when operators explicitly set + `LEGACY_EMAIL_OWNER_USER_ID`. The example env now leaves it blank on purpose, + manual import/restore scripts refuse to proceed without an explicit owner, and + bootstrap fails closed if ownerless email rows still exist without that mapping. +- `/api/calendar/sync` now requires authenticated app identity, ignores any + submitted body `user_token`, and returns 503 until server-side per-user Google + Calendar credentials are available. ## 가설 / Hypothesis -- Keycloak and Casdoor should be evaluated as OIDC providers after mailbox - ownership is modeled and before production multi-user access is claimed. +- Keycloak and Casdoor can now be integrated incrementally through RS256/JWKS + bearer validation without waiting for a gateway-only rollout. +- Full production multi-user claims still depend on mailbox ownership and + tenant-scoped email/search persistence, as documented in `ARCHITECTURE.md`. - Production should require explicit `ENCRYPTION_KEY`, key rotation runbooks, and separate secret scopes for OpenAI, SMTP/IMAP, OAuth, and CI tokens. @@ -22,5 +72,10 @@ - Compare Keycloak and Casdoor on OIDC support, operational complexity, admin UX, self-hosting footprint, backup/restore, and integration with gateway auth. -- Replace dummy auth only after tests prove every email/search/query path is - scoped to the authenticated mailbox owner. +- Replace the temporary legacy-provider bootstrap mapping with a real migration + runbook that can safely map rows per organization instead of one env-scoped + fallback value. +- Document and eventually replace `LEGACY_EMAIL_OWNER_USER_ID` with a real + mailbox/account migration path instead of a one-shot owner backfill. +- Replace the remaining test and operator assumptions with signed bearer or OIDC + sessions as mailbox/account ownership evolves beyond the current bridge fields. diff --git a/docs/operations/email-relay-proxy-boundary.md b/docs/operations/email-relay-proxy-boundary.md index a51a71672..519436992 100644 --- a/docs/operations/email-relay-proxy-boundary.md +++ b/docs/operations/email-relay-proxy-boundary.md @@ -8,8 +8,9 @@ when a tenant SMTP configuration exists; missing SMTP config returns a 400. - `backend/services/imap_worker.py` connects to configured IMAP endpoints as a client; it does not listen for inbound mail or own MX records. -- `backend/api/auth.py` still uses dummy `X-User-Id` auth, so mailbox ownership - and production tenancy are not complete. +- `backend/api/auth.py` derives user scope from verified bearer/OIDC claims. Full + production mailbox ownership still needs a mailbox/account migration beyond the + current bridge owner field. ## 사내망 Self-hosted Runner 아키텍처 (Issue #136) diff --git a/docs/plans/2026-05-13-oidc-integration.md b/docs/plans/2026-05-13-oidc-integration.md new file mode 100644 index 000000000..ee966469c --- /dev/null +++ b/docs/plans/2026-05-13-oidc-integration.md @@ -0,0 +1,104 @@ +# OIDC Integration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the current header-based auth fallback with a real OIDC/JWT validation path suitable for Keycloak or Casdoor while preserving a strict local-only dev mode. + +**Architecture:** Introduce verified token parsing on the backend, map claims into the existing `AuthContext`, and add a minimal frontend login/session path that makes `/settings` and future admin pages depend on real claims rather than browser-controlled headers. Keep a small explicit dev-only auth shim for localhost/LAN UAT. + +**Tech Stack:** FastAPI, PyJWT/JWK validation, Next.js App Router, existing AuthContext, pytest. + +--- + +## Task 1: Backend token validation contract + +**Files:** +- Modify: `backend/core/config.py` +- Modify: `backend/api/auth.py` +- Test: `backend/tests/test_auth_real.py` + +**Step 1: Write failing tests** +- Add tests for bearer-token parsing and claim mapping. +- Add tests that invalid/missing signatures are rejected. +- Add tests that dev-header fallback is only allowed when explicit local mode is on. + +**Step 2: Run tests to verify failure** +Run: `cd backend && DISABLE_BACKGROUND_WORKERS=1 PYTHONWARNINGS=error python3 -m pytest tests/test_auth_real.py -q` +Expected: FAIL. + +**Step 3: Implement minimal backend validation** +- Add OIDC config fields (issuer, audience, jwks url, auth mode). +- Implement JWT/JWK validation. +- Map claims to `AuthContext` fields (`user_id`, `role`, `organization_id`, `group_ids`). + +**Step 4: Verify pass** +Run the same pytest command. +Expected: PASS. + +**Step 5: Commit** +`git add backend/core/config.py backend/api/auth.py backend/tests/test_auth_real.py && git commit -m "feat(auth): add OIDC token validation and claim mapping"` + +## Task 2: Frontend session entrypoint + +**Files:** +- Modify: `frontend/src/lib/api-client.ts` +- Create: `frontend/src/app/login/page.tsx` +- Modify: `frontend/src/app/settings/page.tsx` +- Test: frontend build/lint + +**Step 1: Implement minimal frontend session path** +- Add a login page that explains OIDC/SSO mode and local dev mode. +- Make `apiClient` send Authorization bearer tokens when present. +- Stop relying on default fake user outside explicit dev mode. + +**Step 2: Verify** +Run: `cd frontend && npm test && npm run build && npm run lint` +Expected: PASS. + +**Step 3: Commit** +`git add frontend/src/lib/api-client.ts frontend/src/app/login/page.tsx frontend/src/app/settings/page.tsx && git commit -m "feat(frontend): add OIDC session plumbing"` + +## Task 3: Wire admin/settings to real claims + +**Files:** +- Modify: `backend/api/llm_providers.py` +- Modify: `backend/api/runner_config.py` +- Modify: `frontend/src/app/settings/page.tsx` +- Test: `backend/tests/test_llm_providers_api.py` +- Test: `backend/tests/test_runner_config_api.py` + +**Step 1: Update tests first** +- Replace header-only assumptions with claim-driven fixtures. +- Verify org admins can manage org resources, members cannot. + +**Step 2: Implement minimal code** +- Switch settings/admin APIs to the new token/claim path. +- Keep dev fallback only for local mode. + +**Step 3: Verify** +Run: +- `cd backend && DISABLE_BACKGROUND_WORKERS=1 PYTHONWARNINGS=error python3 -m pytest tests/test_llm_providers_api.py tests/test_runner_config_api.py -q` +- `cd frontend && npm run build && npm run lint` +Expected: PASS. + +**Step 4: Commit** +`git add backend/api/llm_providers.py backend/api/runner_config.py backend/tests/test_llm_providers_api.py backend/tests/test_runner_config_api.py frontend/src/app/settings/page.tsx && git commit -m "fix(auth): connect admin settings to real OIDC claims"` + +## Task 4: Full verification and release path + +**Files:** +- Modify as needed based on review feedback + +**Step 1: Run full backend suite** +`cd backend && DISABLE_BACKGROUND_WORKERS=1 PYTHONWARNINGS=error python3 -m pytest -q` + +**Step 2: Run full frontend suite** +`cd frontend && npm test && npm run build && npm run lint` + +**Step 3: Direct browser UAT** +- verify login page +- verify settings access denied without admin claim +- verify dev-local toggle still works only in explicit dev mode + +**Step 4: Commit final polish** +`git add && git commit -m "fix(auth): polish OIDC integration flow"` diff --git a/docs/plans/2026-05-14-dashboard-rbac-mobile-oidc-remediation.md b/docs/plans/2026-05-14-dashboard-rbac-mobile-oidc-remediation.md new file mode 100644 index 000000000..4bfce16e5 --- /dev/null +++ b/docs/plans/2026-05-14-dashboard-rbac-mobile-oidc-remediation.md @@ -0,0 +1,251 @@ +# Dashboard RBAC Mobile OIDC Remediation Plan + + + + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Close the major gaps between the current Naruon app shell and the branding boards by restoring judgment/action surfaces, fixing scroll and mobile navigation regressions, tightening RBAC exposure, and defining the path to a built-in OIDC provider. + +**Architecture:** Split the work into two tracks. First, repair the presentational shell and interaction model in the existing Next.js frontend without breaking current email/detail flows. Second, isolate RBAC/data-tenancy/OIDC-provider work as backend-backed epics, because several critical issues require schema changes and dedicated auth/session flows rather than UI-only patches. + +**Tech Stack:** Next.js App Router, React 19, Tailwind CSS, FastAPI, SQLAlchemy, PyJWT/JWKS validation, Playwright/Vitest/pytest. + +--- + +## Audit summary captured before implementation + +### Remaining work defined + +1. Restore branded dashboard composition: + - top-level judgment/schedule/action summary row + - right-side context/judgment/action execution rail + - reduce dead copy blocks in sidebar/header +2. Fix shell and mobile scrolling: + - long-form pages must scroll + - mobile menu and bottom nav must not clip or wrap incorrectly + - preserve sidebar scroll position across navigation +3. Restore missing route surfaces: + - `/ai-hub/context` + - `/ai-hub/decisions` + - `/ai-hub/actions` + - mobile-accessible equivalents +4. Add touch interactions: + - swipe email rows left/right into an execution queue / completion flow +5. Reframe “오늘의 인사이트” around email-derived workload signals instead of fake time-tracking claims. +6. Fix frontend RBAC affordances: + - hide/show the correct admin surfaces + - surface blocked/allowed states intentionally +7. Plan backend RBAC/data scope hardening: + - email/search/network tenancy + - calendar auth + - prompt sharing scope +8. Plan built-in OIDC provider path: + - embedded Keycloak or Casdoor + - internal login / callback / logout / session bootstrap + - future SCIM/extensible provider federation + +### Web gaps found against branding + +- Missing dedicated `판단 포인트` surface +- Missing branded right rail composition; graph dominates the third pane +- Multiple dead links in nav +- Sidebar/header dead space dilutes task density +- Missing board-style KPI/action summary row + +### Mobile gaps found + +- Mobile bottom nav wraps because 5 items are rendered inside `grid-cols-4` +- Mobile AI/action workspace never appears (`showMobileActions = false`) +- No swipe/todo gesture path exists +- Long pages are clipped by hidden overflow in shell containers + +### RBAC gaps found + +- Critical backend exposure remains in email/search/network/calendar/prompt-sharing areas and requires follow-up backend scope work +- Group-admin capability is still unimplemented +- Built-in OIDC provider/self-login flow does not exist yet; only manual/local bearer-token plumbing exists + +## Task 1: Repair the shell, dead-space, and scroll contracts + +**Files:** + +- Modify: `frontend/src/components/DashboardLayout.tsx` +- Modify: `frontend/src/app/page.tsx` +- Test: `frontend/src/components/DashboardLayout.test.tsx` + +**Step 1: Write failing tests** + +- Add/extend tests for: + - sidebar scroll position persistence + - mobile bottom-nav 5-column layout + - removal/replacement of dead header/sidebar filler text in favor of actionable density + +**Step 2: Run targeted tests to verify failure** +Run: `cd frontend && npx vitest run src/components/DashboardLayout.test.tsx` +Expected: FAIL. + +**Step 3: Implement minimal shell fixes** + +- Make the main content area scrollable for long pages. +- Preserve sidebar scroll offset on route change. +- Replace dead header chips and sidebar promo blocks with compact, useful state/action blocks. +- Fix bottom-nav geometry for 5 items. + +**Step 4: Run tests/build/lint** +Run: `cd frontend && npx vitest run src/components/DashboardLayout.test.tsx && npm run build && npm run lint` +Expected: PASS. + +## Task 2: Restore the branded dashboard composition + +**Files:** + +- Modify: `frontend/src/app/page.tsx` +- Modify: `frontend/src/components/EmailDetail.tsx` +- Create: `frontend/src/components/TodayInsightPanel.tsx` +- Create: `frontend/src/components/ExecutionRail.tsx` +- Test: `frontend/src/components/EmailDetail.test.tsx` + +**Step 1: Write failing tests** + +- Add tests for the presence of: + - judgment surface (`판단 포인트`) + - execution rail summary/actions beyond the graph + - today-insight concept based on email-derived metrics + +**Step 2: Run targeted tests to verify failure** +Run: `cd frontend && npx vitest run src/components/EmailDetail.test.tsx` +Expected: FAIL. + +**Step 3: Implement minimal dashboard restoration** + +- Add top KPI/action cards aligned with the board. +- Move summary/judgment/actions to a dedicated execution rail. +- Keep the graph as a secondary visual, not the only third-pane content. +- Rework copy so the product emphasizes context/judgment/action rather than “AI” as a primary actor. + +**Step 4: Run tests/build/lint** +Run: `cd frontend && npx vitest run src/components/EmailDetail.test.tsx && npm run build && npm run lint` +Expected: PASS. + +## Task 3: Restore missing AI hub subroutes and mobile IA + +**Files:** + +- Modify: `frontend/src/app/ai-hub/page.tsx` +- Create: `frontend/src/app/ai-hub/context/page.tsx` +- Create: `frontend/src/app/ai-hub/decisions/page.tsx` +- Create: `frontend/src/app/ai-hub/actions/page.tsx` +- Modify: `frontend/src/components/DashboardLayout.tsx` +- Test: `frontend/src/app/layout.test.tsx` + +**Step 1: Write failing tests** + +- Add route/nav assertions for real context/decisions/actions destinations. +- Add mobile-nav/menu assertions for accessible AI workspace paths. + +**Step 2: Run targeted tests to verify failure** +Run: `cd frontend && npx vitest run src/app/layout.test.tsx src/components/DashboardLayout.test.tsx` +Expected: FAIL. + +**Step 3: Implement minimal IA restoration** + +- Back the existing nav links with real pages. +- Make mobile nav and menu expose the same IA without broken wrapping. + +**Step 4: Run tests/build/lint** +Run: `cd frontend && npx vitest run src/app/layout.test.tsx src/components/DashboardLayout.test.tsx && npm run build && npm run lint` +Expected: PASS. + +## Task 4: Add mobile swipe-to-execution interaction + +**Files:** + +- Modify: `frontend/src/components/EmailList.tsx` +- Create: `frontend/src/lib/execution-queue.ts` +- Create: `frontend/src/lib/execution-queue.test.ts` +- Create: `frontend/src/lib/email-list-gestures.test.tsx` + +**Step 1: Write failing tests** + +- Add tests for swipe-right → add to execution queue. +- Add tests for swipe-left → mark queue item done/remove from queue. + +**Step 2: Run targeted tests to verify failure** +Run: `cd frontend && npx vitest run src/lib/execution-queue.test.ts src/lib/email-list-gestures.test.tsx` +Expected: FAIL. + +**Step 3: Implement minimal gesture flow** + +- Add local execution-queue persistence. +- Add pointer/touch gesture handling with visible affordances. +- Keep non-touch click behavior intact. + +**Step 4: Run tests/build/lint** +Run: `cd frontend && npx vitest run src/lib/execution-queue.test.ts src/lib/email-list-gestures.test.tsx && npm run build && npm run lint` +Expected: PASS. + +## Task 5: Harden frontend RBAC affordances around admin surfaces + +**Files:** + +- Modify: `frontend/src/app/settings/page.tsx` +- Modify: `frontend/src/lib/api-client.ts` +- Test: `frontend/src/app/settings/page.test.tsx` + +**Step 1: Extend failing tests** + +- Cover group-admin/member/platform-admin/organization-admin visibility and blocked states. + +**Step 2: Run targeted tests to verify failure** +Run: `cd frontend && npx vitest run src/app/settings/page.test.tsx` +Expected: FAIL. + +**Step 3: Implement minimal RBAC UI hardening** + +- Surface clear “implemented vs unavailable” messaging by role. +- Avoid implying capabilities that the backend still cannot safely grant. + +**Step 4: Run tests/build/lint** +Run: `cd frontend && npx vitest run src/app/settings/page.test.tsx && npm run build && npm run lint` +Expected: PASS. + +## Task 6: Backend RBAC/data-scope hardening follow-up + +**Files:** + +- Modify: `backend/db/models.py` +- Modify: `backend/api/emails.py` +- Modify: `backend/api/search.py` +- Modify: `backend/api/network.py` +- Modify: `backend/api/calendar.py` +- Modify: `backend/api/prompts.py` +- Add/modify tests across affected suites + +**Notes:** + +- This is a separate schema-and-contract epic. +- Must not be mixed casually with frontend-only polish. +- Requires mailbox ownership columns, query filters, and likely migration scripts. + +## Task 7: Built-in OIDC provider/self-login epic + +**Candidate direction:** embedded Keycloak first, Casdoor as secondary evaluation path. + +**Files likely involved:** + +- `docker-compose.gateway.yml` +- new auth/login callback endpoints +- frontend login/callback/session bootstrap pages +- docs under `docs/operations/` + +**Must cover:** + +- built-in OIDC login even without an external provider +- future SCIM/federation extension path +- role/group mapping into `ScopedRoleAssignment` + +**Notes:** + +- This is larger than the current remediation slice. +- Delivery path should start with architecture/runbook + local stack integration, then real login flow. diff --git a/docs/plans/2026-05-14-naruon-foundation-remediation-implementation.md b/docs/plans/2026-05-14-naruon-foundation-remediation-implementation.md new file mode 100644 index 000000000..a87ba700f --- /dev/null +++ b/docs/plans/2026-05-14-naruon-foundation-remediation-implementation.md @@ -0,0 +1,132 @@ +# Naruon Foundation Remediation Implementation Plan + + + + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Close the immediate UI, mobile scroll, route discoverability, and RBAC leakage gaps found while preserving the larger OIDC/synthetic-mailbox roadmap as staged epics. + +**Architecture:** Keep this slice small and safety-first. Frontend changes repair the existing Next.js shell and mobile affordances without inventing unavailable product data. Backend changes enforce authenticated user/org scoping on calendar, network, and prompt/provider paths before UI polish claims those surfaces are safe. + +**Tech Stack:** Next.js App Router, React 19, Tailwind CSS, Vitest, Playwright, FastAPI, SQLAlchemy, pytest, PyJWT/JWKS auth context. + +--- + +## Task 1: Restore route discoverability and header search honesty + +**Files:** + +- Modify: `frontend/src/components/DashboardLayout.tsx` +- Modify: `frontend/src/components/DashboardLayout.test.tsx` +- Modify: `frontend/tests/e2e/dashboard-branding.spec.ts` + +**Step 1: Write failing tests** + +Add assertions that desktop navigation exposes `설정` and `프롬프트 스튜디오`, mobile drawer exposes compose/starred/sent/drafts/all/projects/labels/settings, mobile settings uses a settings icon label, and header search submit navigates to `/ai-hub/context?q=`. + +**Step 2: Run test to verify failure** + +Run: `cd frontend && npx vitest run src/components/DashboardLayout.test.tsx` +Expected: FAIL because the links and search submit are not wired yet. + +**Step 3: Implement minimal code** + +- Add `Settings` icon import. +- Add desktop navigation entries for settings and prompt studio. +- Add an expanded mobile drawer route map while keeping the five-item bottom nav compact. +- Replace the mobile settings icon with `Settings`. +- Wrap header search in a form and route to `/ai-hub/context?q=...`. + +**Step 4: Run tests** + +Run: `cd frontend && npx vitest run src/components/DashboardLayout.test.tsx` +Expected: PASS. + +## Task 2: Fix mobile scroll and non-swipe execution affordances + +**Files:** + +- Modify: `frontend/src/components/DashboardLayout.tsx` +- Modify: `frontend/src/components/EmailList.tsx` +- Modify: `frontend/src/components/EmailDetail.tsx` +- Modify: `frontend/src/components/EmailList.test.tsx` +- Modify: `frontend/tests/e2e/dashboard-branding.spec.ts` + +**Step 1: Write failing tests** + +Add tests for visible row fallback buttons: “실행 목록에 추가” and “완료 처리”. Add Playwright checks that mobile last interactive content is above the fixed bottom nav after scrolling. + +**Step 2: Run test to verify failure** + +Run: `cd frontend && npx vitest run src/components/EmailList.test.tsx` +Expected: FAIL because swipe-only actions have no tap fallback. + +**Step 3: Implement minimal code** + +- Add `min-h-0` to `ScrollArea` callers. +- Add safe-area-aware bottom padding to the main scroll section and bottom nav. +- Add tap fallback buttons per email row for queue and completion. + +**Step 4: Run tests** + +Run: `cd frontend && npx vitest run src/components/EmailList.test.tsx && npm run build && npm run lint` +Expected: PASS with zero warnings. + +## Task 3: Enforce RBAC on calendar/network/prompt paths + +**Files:** + +- Modify: `backend/api/calendar.py` +- Modify: `backend/api/network.py` +- Modify: `backend/api/prompts.py` +- Modify: `backend/tests/test_calendar_api.py` +- Modify: `backend/tests/test_network_api.py` +- Modify: `backend/tests/test_prompts_api.py` + +**Step 1: Write failing tests** + +Add tests that unauthenticated calendar sync is rejected, network graph excludes another user's emails, and prompt test selects only providers scoped to the caller organization. + +**Step 2: Run tests to verify failure** + +Run: `cd backend && DISABLE_BACKGROUND_WORKERS=1 PYTHONWARNINGS=error python3 -m pytest tests/test_calendar_api.py tests/test_network_api.py tests/test_prompts_api.py -q` +Expected: FAIL on the new negative cases. + +**Step 3: Implement minimal code** + +- Add `AuthContext` dependency to calendar sync and remove body-token trust. +- Keep calendar sync fail-closed with 503 until server-side per-user Google + credentials exist; do not translate `AuthContext` claims into Google OAuth + credentials. +- Filter network graph query by authenticated `Email.user_id`. +- Scope prompt provider lookup by `auth_context.organization_id` and fail closed when no org-scoped provider exists. + +**Step 4: Run tests** + +Run: same pytest command. +Expected: PASS with zero warnings. + +## Task 4: Sync roadmap docs with actual gap inventory + +**Files:** + +- Modify: `ARCHITECTURE.md` +- Modify: `docs/plans/2026-05-14-synthetic-mailbox-platform-program.md` +- Modify: `docs/plans/2026-05-14-dashboard-rbac-mobile-oidc-remediation.md` + +**Step 1: Update docs** + +Record the new design/implementation plan, remaining route/screen gaps vs branding, mobile scroll/RBAC findings, and the exact deferred platform epics. +Include the ownerless-email bootstrap guard and the calendar credential-store +gap so docs do not imply legacy data or calendar sync silently keep working. + +**Step 2: Verify docs and code together** + +Run targeted backend/frontend tests, then full feasible gates: + +- `cd backend && DISABLE_BACKGROUND_WORKERS=1 PYTHONWARNINGS=error python3 -m pytest -q` +- `cd frontend && npm test && npm run build && npm run lint` +- `cd frontend && npm run test:e2e -- tests/e2e/dashboard-branding.spec.ts` + +Expected: PASS with zero warnings or a documented exact blocker. diff --git a/docs/plans/2026-05-14-naruon-full-scope-remediation-design.md b/docs/plans/2026-05-14-naruon-full-scope-remediation-design.md new file mode 100644 index 000000000..553eb9a23 --- /dev/null +++ b/docs/plans/2026-05-14-naruon-full-scope-remediation-design.md @@ -0,0 +1,99 @@ +# Naruon Full-Scope Remediation Design + + + + +## 목적 + +사용자가 요청한 27개 항목을 단일 화면 수정이 아니라 제품·보안·도메인 프로그램으로 정리한다. 이번 브랜치에서 이미 닫은 항목과 아직 남은 항목을 분리하고, 즉시 시정 가능한 UI/RBAC/스크롤 결함은 이 브랜치에서 고친다. OIDC 내장 공급자, synthetic mailbox, semantic layer, LLM 라우터, DAV/MCP는 안전한 선후관계를 가진 후속 에픽으로 둔다. + +## 현재 근거 + +- `ARCHITECTURE.md`는 현재 상태를 bridge 단계로 설명한다. `Email.user_id`, `Email.mailbox_account_id`, `MailboxAccount`, `ExecutionItem`은 존재하지만 full `Mailbox` aggregate, persisted IMAP/POP/OAuth ingestion, synthetic aggregation은 아직 없다. +- `frontend/branding/uiux` 보드는 로그인, 메일, 맥락 검색, 일정/실행, 설정, 보안/RBAC, 데이터/문서, AI Hub/workflow를 모두 포함한다. +- 현재 feature worktree에는 `frontend/branding`이 없고, 원본은 repository main worktree의 untracked asset으로 남아 있다. 디자인 비교는 `/home/seongho/ai_email_client/frontend/branding/uiux`를 read-only truth source로 사용한다. +- parallel audit 결과, 즉시 시정 가능한 결함은 mobile route parity, global search dead affordance, desktop settings/prompt navigation, calendar/network/prompt RBAC gaps, mobile scroll/safe-area gaps이다. + +## 접근안 + +### 접근 A: 디자인 전체를 한 번에 구현 + +- 장점: 보드와 웹의 차이를 빠르게 줄인다. +- 단점: 내장 OIDC, synthetic mailbox, LLM runtime, DAV/MCP가 모두 도메인/보안 경계 변경을 요구해 회귀 위험이 크다. + +### 접근 B: 안전한 foundation slice 우선 구현 + +- 장점: RBAC/data leakage와 mobile usability를 먼저 닫고, 이후 대형 플랫폼 에픽이 올라갈 수 있는 경계를 만든다. +- 단점: 보드에 있는 보안/데이터/워크플로우 전체 화면은 한 PR 안에서 완성되지 않는다. + +### 접근 C: 문서화만 하고 구현 보류 + +- 장점: 위험이 거의 없다. +- 단점: 사용자가 명시한 dead space, scroll, RBAC 접근성 결함이 계속 남는다. + +## 결정 + +접근 B를 선택한다. 즉시 구현 slice는 “사용자가 오늘 체감하는 결함”과 “보안상 열려 있으면 안 되는 결함”에 집중한다. + +1. 모바일과 데스크톱에서 주요 route가 닫히지 않게 한다. +2. 검색 affordance는 실제 context route로 이동시킨다. 구현되지 않은 전역 search API를 꾸며내지 않는다. +3. scroll/safe-area는 화면 크기와 DPI가 달라도 마지막 interactive element가 가려지지 않도록 고친다. +4. RBAC는 backend에서 먼저 막는다. UI gating은 보조 장치다. +5. AI라는 단어는 기술 표기로만 제한하고, product surface는 맥락 종합, 판단 포인트, 실행 항목 중심으로 유지한다. + +## 이번 slice 설계 + +### UI/IA + +- `DashboardLayout.tsx` + - desktop sidebar에 설정과 프롬프트/작업 설계 진입점을 추가한다. + - mobile drawer는 compact bottom nav가 담지 못하는 전체 route map을 제공한다. + - mobile settings icon을 calendar icon에서 settings icon으로 교체한다. + - header search는 submit 시 `/ai-hub/context?q=...`로 이동한다. + - `h-screen`/fixed bottom nav 조합을 safe viewport + safe-area padding 기반으로 조정한다. + +- `EmailList.tsx` + - swipe는 유지하되 tap 가능한 “실행 목록”/“완료 처리” fallback을 추가한다. + - `ScrollArea` caller에 `min-h-0`를 보강한다. + +- `EmailDetail.tsx` + - nested scroll area가 flex parent 안에서 실제로 줄어들 수 있도록 `min-h-0`를 보강한다. + +### RBAC/security + +- `backend/api/calendar.py` + - 현재 calendar sync는 body의 `user_token`을 받으며 auth dependency가 없다. 이번 slice에서는 authenticated context를 필수화하고, body token을 제거하거나 무시하는 방향으로 fail-closed한다. + - 서버 측 per-user Google Calendar credential store가 아직 없으므로, `AuthContext`를 Google credential처럼 넘기지 않는다. credential store가 생기기 전까지 sync는 503으로 닫힌다. + +- `backend/api/network.py` + - network graph query는 `Email.user_id == current_user`를 강제한다. + +- `backend/api/prompts.py` + - prompt test는 active provider를 organization scope로 제한한다. + - shared prompt는 현재 global 공유다. schema migration 없이 즉시 완성하기 어려우므로 이번 slice에서는 route access와 provider leakage를 먼저 막고, org-scoped prompt sharing은 별도 에픽으로 남긴다. + +### 모바일 검증 + +- Playwright는 최소 360x800, 375x667, 430x932, 768x1024와 device scale factor 2~3을 포함한다. +- route parity, bottom nav overlap, menu scroll, inbox/detail/settings scroll reachability를 확인한다. + +## 대형 에픽 경계 + +1. 내장 OIDC provider: Keycloak first, Casdoor alternative. app-owned `/login` → `/auth/callback` → httpOnly cookie session이 목표다. +2. Mailbox aggregate: `MailboxAccount`는 설정 aggregate이고, 저장된 email/source/attachment/thread lineage를 표현하는 `Mailbox` aggregate가 필요하다. +3. Synthetic mailbox: Gmail/iCloud/Outlook/company runner 수집 후 중복 collapse, source-account reply routing, upstream redistribution을 지원한다. +4. Execution/WBS: `ExecutionItem`은 source emails, participants, attachments, inferred due date, evidence snippets를 갖고, 필요 시 Project/WBS로 승격된다. +5. Semantic layer: email body와 attachment를 evidence/provenance 단위로 구조화한다. +6. Jargon service: community/sender graph 기반 용어 후보와 의미 가설을 추적한다. +7. LLM runtime: provider account, model endpoint, routing policy, budget policy, execution record로 분리한다. TPM/RPM, fallback, hedged, ensemble 전략을 지원한다. +8. DAV bridge: internal calendar/file/note canonical model 이후 CalDAV/WebDAV export/sync를 붙인다. +9. MCP plane: model provider가 아니라 별도 tool execution plane으로 permission/audit/deny-by-default 정책을 둔다. +10. Landing preference: user preference로 Dashboard Portal 기본값, Inbox 등 선택지를 제공한다. + +## 검증 기준 + +- 프론트엔드 변경: Vitest targeted tests, `npm run build`, `npm run lint`, Playwright desktop/mobile. +- 백엔드 변경: affected pytest with `PYTHONWARNINGS=error`, auth/RBAC negative tests 포함. +- Bootstrap 변경: ownerless legacy email row가 있는데 `LEGACY_EMAIL_OWNER_USER_ID`가 비어 있으면 startup에서 명시적으로 실패해야 한다. +- 경고는 실패로 취급한다. +- 문서: `ARCHITECTURE.md`와 plan docs가 runtime behavior와 불일치하면 갱신한다. diff --git a/docs/plans/2026-05-14-synthetic-mailbox-platform-program.md b/docs/plans/2026-05-14-synthetic-mailbox-platform-program.md new file mode 100644 index 000000000..88bed6e35 --- /dev/null +++ b/docs/plans/2026-05-14-synthetic-mailbox-platform-program.md @@ -0,0 +1,234 @@ +# Synthetic Mailbox Platform Program Plan + + + + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Define the execution path for the large platform epics behind built-in OIDC, synthetic multi-mailbox aggregation, action/project/WBS modeling, semantic attachment handling, DAV/mobile sync, and multi-provider LLM orchestration. + +**Architecture:** Treat this as a staged domain and platform program, not a single feature. The safe order is: tenancy and mailbox ownership first, identity and real login second, synthetic aggregation and execution modeling third, then mobile/DAV/provider/MCP extensions. + +**Tech Stack:** FastAPI, PostgreSQL, Next.js, IMAP/SMTP/OAuth provider adapters, embedded Keycloak, PyJWT/JWKS, pgvector/semantic indexing, provider-neutral LLM adapters, MCP runtime. + +--- + +## Program-level findings already established + +### Delivered in this branch already + +- desktop dashboard summary cards + context/judgment/execution rail +- mobile five-item bottom navigation and scroll restoration +- server-backed personal `ExecutionItem` aggregate with email-linked queueing +- compose route + honest placeholder routes for primary nav destinations +- loopback-only local compose auth escape hatch + runtime-gated localhost auth UI +- first owner-scoped email boundary via `Email.user_id`, plus owner-filtered list/detail/thread APIs +- `MailboxAccount` aggregate with multi-account personal registry, default reply account, settings UI, and send-path selection support +- `Email.mailbox_account_id` field + compose preselection for replies when the source mail already knows its mailbox account +- fixture/ZIP import paths now attempt to resolve `mailbox_account_id` from active mailbox accounts for the owner +- inbox API/UI now carry mailbox-account provenance and can filter the inbox by linked account +- mailbox-scoped search now preserves `mailbox_account_id` and respects the selected inbox account filter +- mailbox-filtered thread and search reads include authenticated-owner legacy + `mailbox_account_id IS NULL` rows during the bridge period so restored thread + context is not silently split before the full mailbox migration +- execution items now preserve source mailbox/account provenance and a snippet so the action board can stay mailbox-aware too +- IMAP sync foundation now enumerates active `MailboxAccount` rows instead of `TenantConfig`, preparing multi-account ingestion +- POP3 sync foundation now also enumerates active `MailboxAccount` rows, with per-account timeout/error handling and UI/API support for POP3 credentials +- mailbox account writes now normalize/sanitize fields and enforce create/update invariants (default reply implies active, server/port pair validation, duplicate-account 409, missing encryption 503) +- mobile workspace drawer now has modal-style close/focus behavior, and Prompt Studio is treated as an admin/provider-backed surface rather than a member utility + +### Domain model + +- `Organization / Workspace` is the billing/isolation boundary. +- `Mailbox` must become a first-class aggregate, replacing the current `TenantConfig` overloading. +- `MailThread` is the true work object, not individual mail rows. +- `ExecutionItem` must be promoted from local/UI-only queue + LLM todos into a server-backed aggregate. +- `Project / WBS` is a downstream planning layer, not the first persistence target. + +### Built-in OIDC + +- Embedded **Keycloak first** is the recommended built-in provider path. +- App-owned OIDC session (`/login` → `/auth/callback` → httpOnly cookie) is preferred over a gateway-only forward-auth architecture. +- External federation and later SCIM should remain phase-2/3 layers. + +### LLM platform + +- The repo has org-scoped provider CRUD, but runtime is still effectively OpenAI-centric. +- Prompt Studio can test prompts only for workspace admins because provider-backed + execution consumes organization LLM configuration; member-safe prompt drafting + or quotas need a separate policy path. +- The long-term platform needs: + - provider registry + model catalog + - per-task routing policy + - RPM/TPM budgets + - same-provider and cross-provider fallback chains + - optional ensemble execution + - MCP as a separate execution plane + +### Mobile / sync / synthetic product boundary + +- Safe MVP remains responsive web + one real mailbox per member + correct thread restore. +- DAV/native-mobile/synthetic multi-account fusion should follow mailbox ownership and reply-routing hardening. + +## Epic A: Mailbox ownership and synthetic inbox foundation + +**Why first:** almost every advanced request depends on mailbox ownership. + +**Current state:** `Email.user_id` exists, `/api/emails` reads are owner-filtered, `ExecutionItem` queueing now requires that owner match, legacy owner backfill/import paths require explicit operator input, and `MailboxAccount` now exists as the first mailbox-configuration aggregate. The system still lacks `Email.mailbox_id`, provider/source identity on stored messages, and org/workspace-aware multi-mailbox ownership. + +**Must deliver:** + +1. `emails` ownership columns (`member_id` and/or `mailbox_id`) +2. `attachments` ownership inheritance +3. all email/search/network queries filtered by mailbox/member/org scope +4. deterministic duplicate and thread restore path for live sync and zip restore + +**Key files likely affected:** + +- `backend/db/models.py` +- `backend/api/emails.py` +- `backend/api/search.py` +- `backend/api/network.py` +- `backend/import_fixtures.py` +- `backend/services/archive.py` +- `docs/threading-contract.md` + +## Epic B: Execution items, email-task matching, and project/WBS handoff + +**Product rule:** 메일과 할일은 정확히 매칭되어야 하며, 메일 내용/발신인/첨부/관계 단서를 함께 사용해야 한다. + +**Phased model:** + +1. Promote `ExecutionItem` to backend aggregate with source email/thread refs. +2. Add multi-source evidence fields: + - source emails + - senders/participants + - attachment refs + - inferred due date / coordination type +3. Add “promote to project / WBS” action when the item exceeds simple follow-up scope. + +**Boundaries:** + +- execution item = email-derived operational work +- project/WBS node = explicit planning object after triage + +## Epic C: Built-in OIDC login with external federation path + +**Recommended path:** embedded Keycloak. + +**Phase order:** + +1. compose stack with `keycloak` + dedicated DB +2. seeded realm/client/groups/roles +3. frontend `/login`, `/auth/callback`, `/logout` +4. httpOnly session cookie handling +5. backend strict RS256/JWKS path + claim mapping +6. map token identity to app-owned `ScopedRoleAssignment` +7. add future external IdP federation through Keycloak broker + +**Later:** SCIM provisioning bridge. + +## Epic D: Synthetic multi-mailbox aggregation + +**Goal:** Gmail / Outlook / iCloud / company runner mailboxes appear as one synthetic workspace, but replies and upstream updates remain account-correct. + +**Phases:** + +1. multiple Mailboxes per member +2. account-aware send/reply routing +3. duplicate message collapse across forwarded/copied mailboxes +4. synthetic unified inbox and task/calendar/note overlays +5. upstream redistribution/sync rules back to source accounts where supported + +## Epic E: Semantic layer for emails and attachments + +**Goal:** 이메일과 첨부파일 모두를 semantic layer로 처리하되, 첨부는 항상 부모 스레드와 연결된다. + +**Deliverables:** + +- attachment parsing pipeline +- structured attachment metadata +- semantic evidence references back to parent email/thread +- support for office docs / excel / image reasoning provenance + +## Epic F: Jargon disambiguation service + +**Problem:** terms like `PM` are ambiguous by community. + +**Minimal direction:** + +1. extract jargon candidates from email corpora +2. cluster by sender/community/recipient neighborhood +3. maintain per-community glossary hypotheses with evidence snippets +4. feed disambiguated meaning into summary/judgment/action generation + +## Epic G: LLM provider control plane and runtime router + +**Control plane objects:** + +- `ProviderAccount` +- `ModelEndpoint` +- `RoutingPolicy` +- `RuntimeBudgetPolicy` + +**Runtime layers:** + +1. capability filter +2. policy selector +3. execution strategy (`single`, `fallback`, `hedged`, `ensemble`) +4. provider adapter transport + +**Required provider families to design for:** + +- Anthropic +- Vertex AI (API key / SA key) +- Vertex third-party models +- GitHub Copilot +- OpenAI OAuth / API key +- LiteLLM +- Gemini API +- OpenRouter +- OpenAI-compatible endpoints + +## Epic H: DAV / mobile ecosystem bridge + +**Goal:** derived files, schedules, notes should eventually surface into iOS/macOS-native surfaces where feasible. + +**Phase order:** + +1. internal canonical note/file/calendar models +2. CalDAV export/sync for events +3. WebDAV or file export bridge for documents/artifacts +4. later note sync integration where product fit is proven + +## Epic I: MCP tool access plane + +**Rule:** MCP is not just another model option; it is a separate execution plane. + +**Needs:** + +- task-scoped tool profiles +- model/tool capability compatibility map +- permission + audit logging +- deny-by-default external/network tool policy + +## Epic J: Per-user landing page preference + +**Low-risk later slice:** + +- add per-member preference +- allow Dashboard Portal / Email / future workspace home choice +- default remains Dashboard Portal + +## Recommended delivery order + +1. Mailbox ownership and query scoping +2. Built-in OIDC login/session +3. ExecutionItem aggregate and email-task matching +4. Multi-mailbox account routing +5. Synthetic inbox/task/calendar overlay +6. Semantic attachment layer +7. LLM router/budget/fallback control plane +8. DAV/mobile ecosystem bridge +9. Jargon disambiguation service +10. MCP tool plane diff --git a/docs/plans/2026-05-15-dashboard-mobile-rbac-product-remediation.md b/docs/plans/2026-05-15-dashboard-mobile-rbac-product-remediation.md new file mode 100644 index 000000000..c7c5fbe2c --- /dev/null +++ b/docs/plans/2026-05-15-dashboard-mobile-rbac-product-remediation.md @@ -0,0 +1,170 @@ +# Dashboard Mobile RBAC Product Remediation Plan + + + + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 대시보드/모바일/권한 노출을 현재 브랜치에서 작게 닫고, OIDC·합성 메일함·프로젝트/WBS·DAV·MCP 같은 제품 에픽은 별도 경계로 남긴다. + +**Architecture:** 즉시 수정 가능한 Next.js shell·접근성·RBAC affordance와 FastAPI 권한 검사를 같은 slice에서 검증한다. 스키마 변경, built-in OIDC provider, mailbox aggregate, LLM runtime/router, DAV/mobile bridge, MCP execution plane은 현재 foundation 위의 후속 bounded context로 분리한다. + +**Tech Stack:** Next.js App Router, React 19, Tailwind CSS, Vitest, Playwright, FastAPI, SQLAlchemy, PyJWT/JWKS, pytest. + +--- + +## 현재 근거와 제약 + +- PR #200은 `613cc58c475b4f15bd06368ff923c5412500d461` 기준 checks는 green이고 CodeRabbit status도 success이지만, repository ruleset `14316398`이 `required_approving_review_count=1` 및 `require_last_push_approval=true`로 남아 GitHub merge gate가 `REVIEW_REQUIRED`를 반환한다. +- repo policy의 default merge source는 current-head CodeRabbit evidence이며 human review 대기는 기본값이 아니다. 단, `--admin` bypass는 명시 요청 없이 사용하지 않는다. +- 기존 `2026-05-14-dashboard-rbac-mobile-oidc-remediation.md`는 넓은 감사 계획이다. 이 문서는 실제로 닫을 작은 slice와 후속 에픽 경계를 다시 고정한다. +- 디자인 원본은 `/home/seongho/ai_email_client/frontend/branding/uiux/`이고, 사용자-facing copy는 AI 자체보다 맥락 종합, 판단 포인트, 실행 항목을 앞세운다. + +## Scope and non-goals + +### 이번 slice + +1. `DashboardLayout` 모바일 drawer를 dialog로 만들고, backdrop, Escape, outside click, focus return, scroll reachability를 검증한다. +2. `AI Hub` 접근성 label과 metadata copy를 워크스페이스/맥락 중심으로 바꾼다. URL `/ai-hub/*`는 유지한다. +3. Prompt Studio를 admin-like surface로 분류한다. frontend nav/direct route와 backend prompt-sharing/provider-backed test API를 fail-closed로 맞춘다. +4. legacy dev-header auth UI와 API client header emission을 제거하고 bearer/OIDC 세션만 남긴다. + +### 이번 slice의 non-goals + +- `/ai-hub/*` route rename. +- full `Mailbox` aggregate migration. +- platform admin org selector 설계. +- built-in Keycloak/Casdoor OIDC provider 도입. +- synthetic mailbox, project/WBS promotion, attachment semantic layer, jargon disambiguation, DAV/mobile bridge, MCP execution plane 구현. + +## Task 1: Dashboard shell and mobile dialog affordance + +**Files:** + +- Modify: `frontend/src/components/DashboardLayout.tsx` +- Modify: `frontend/src/components/DashboardLayout.test.tsx` +- Modify: `frontend/src/app/layout.tsx` +- Modify: `frontend/src/app/layout.test.tsx` +- Modify: `frontend/tests/e2e/dashboard-branding.spec.ts` + +**Step 1: Write failing tests** + +- Assert desktop workspace nav uses `워크스페이스 맥락 메뉴` instead of `AI Hub sections`. +- Assert mobile drawer has `role="dialog"`, `aria-modal="true"`, labelled title, backdrop, Escape close, backdrop/outside close, link close, and focus return to the opener. +- Assert app metadata says `Naruon | 메일 워크스페이스` and describes 이메일·일정·관계·판단 포인트. + +**Step 2: Run targeted tests to verify failure** + +Run: `cd frontend && npx vitest run src/components/DashboardLayout.test.tsx src/app/layout.test.tsx` +Expected: FAIL before implementation. + +**Step 3: Implement minimal code** + +- Add `menuButtonRef` and `drawerRef`. +- Render a `data-testid="mobile-workspace-backdrop"` backdrop at `z-40` below the drawer. +- Add `role="dialog"`, `aria-modal="true"`, `aria-labelledby="mobile-workspace-menu-title"`. +- Close on Escape, backdrop click, outside pointer down, route link click, and return focus to the menu button. +- Keep sidebar and main scroll contracts unchanged except moving all related quick links into reachable scroll regions if tests expose clipping. + +**Step 4: Verify green** + +Run: `cd frontend && npx vitest run src/components/DashboardLayout.test.tsx src/app/layout.test.tsx` +Expected: PASS. + +## Task 2: Prompt Studio and dev-auth RBAC hardening + +**Files:** + +- Modify: `frontend/src/lib/api-client.ts` +- Modify: `frontend/src/lib/api-client.test.ts` +- Modify: `frontend/src/components/DevAuthSwitcher.tsx` +- Modify: `frontend/src/app/prompt-studio/page.tsx` +- Create: `frontend/src/app/prompt-studio/page.test.tsx` +- Modify: `frontend/src/components/DashboardLayout.tsx` +- Modify: `frontend/src/components/DashboardLayout.test.tsx` +- Modify: `backend/api/prompts.py` +- Modify: `backend/tests/test_prompts_api.py` + +**Step 1: Write failing tests** + +- `ApiClient.canManageWorkspaceSettings()` is false for local `admin` before runtime dev-header auth is loaded/enabled. +- `DevAuthSwitcher` renders no identity switcher even when a legacy runtime flag is mocked on. +- member direct visit to Prompt Studio sees a blocked state and no save/test controls. +- organization/platform admin sees Prompt Studio controls. +- backend member cannot create `is_shared=true` prompts. +- backend member cannot call `/api/prompts/test` against organization provider. + +**Step 2: Run targeted tests to verify failure** + +Run: + +```bash +cd frontend && npx vitest run src/lib/api-client.test.ts src/components/DashboardLayout.test.tsx src/app/prompt-studio/page.test.tsx +cd backend && DISABLE_BACKGROUND_WORKERS=1 PYTHONWARNINGS=error python3 -m pytest tests/test_prompts_api.py -q +``` + +Expected: FAIL before implementation. + +**Step 3: Implement minimal code** + +- Add a single frontend capability helper path: admin affordances require bearer claims with org scope. +- Remove dev-header identity switching from `DevAuthSwitcher`. +- Hide Prompt Studio nav for non-admin users and render direct-route blocked state. +- Backend require `platform_admin` or `organization_admin` for shared prompts and provider-backed prompt tests. +- Preserve member-owned private prompts. + +**Step 4: Verify green** + +Run the same targeted frontend/backend commands. Expected: PASS with warnings as errors. + +## Task 3: Product epic boundary sync + +**Files:** + +- Modify: `ARCHITECTURE.md` +- Modify: `docs/operations/auth-key-management.md` +- Modify: `docs/plans/2026-05-14-synthetic-mailbox-platform-program.md` + +**Step 1: Update docs only after code behavior is known** + +- Document Prompt Studio as admin/provider-backed until a member-safe quota/policy exists. +- Document dev-header auth as removed from backend request authentication. +- Keep full production multi-user claims out of docs until `Mailbox` aggregate and org/workspace query scoping are finished. + +**Step 2: Verify docs** + +Run: `PYTHONPATH="${OPENCODE_HOME:-$HOME/.config/opencode}" python3 -m scripts.lint_by_filetype --json` +Expected: PASS. + +## Verification matrix + +| Area | Command | Expected | +|---|---|---| +| Dashboard/mobile unit | `cd frontend && npx vitest run src/components/DashboardLayout.test.tsx src/app/layout.test.tsx` | PASS | +| Frontend RBAC unit | `cd frontend && npx vitest run src/lib/api-client.test.ts src/app/prompt-studio/page.test.tsx` | PASS | +| Backend prompt RBAC | `cd backend && DISABLE_BACKGROUND_WORKERS=1 PYTHONWARNINGS=error python3 -m pytest tests/test_prompts_api.py -q` | PASS | +| E2E branding/mobile | `cd frontend && npm run test:e2e -- tests/e2e/dashboard-branding.spec.ts` | PASS or exact local-server blocker | +| Changed-file lint | `PYTHONPATH="${OPENCODE_HOME:-$HOME/.config/opencode}" python3 -m scripts.lint_by_filetype --json` | PASS | + +## Product epic boundaries + +- **Mailbox aggregate:** replace bridge fields with first-class mailbox/org/workspace ownership and query scoping. +- **Synthetic multi-mailbox inbox:** merge per-account streams with provenance and dedupe policy. +- **ExecutionItem → Project/WBS:** promote email-derived action items into project/task aggregates. +- **Built-in OIDC provider/session:** Keycloak-first, Casdoor alternative, future SCIM/federation. +- **Attachment semantic layer:** parse, index, classify, and permission-check attachment-derived context. +- **Jargon disambiguation:** org/workspace glossary and per-thread term resolution. +- **LLM runtime/router:** provider selection, quota, audit, and prompt-policy enforcement. +- **DAV/mobile bridge:** CalDAV/CardDAV/WebDAV plus mobile-client operational path. +- **MCP execution plane:** tool execution contracts, approvals, audit trail, and failure isolation. + +## File-level intent table + +| File | Change(add/edit/delete/move) | Intent(의도) | Why(이유) | Risk/Notes | +|---|---|---|---|---| +| `frontend/src/components/DashboardLayout.tsx` | edit | 모바일 drawer와 nav affordance 정리 | 접근성·스크롤·권한 노출을 UI에서 먼저 fail-closed로 맞춤 | route rename은 하지 않음 | +| `frontend/src/lib/api-client.ts` | edit | admin capability 판정을 bearer claim 기준으로 통일 | dev header가 켜진 것처럼 보이는 legacy flag로 admin UI가 열리는 문제 차단 | bearer claim path 보존 | +| `frontend/src/components/DevAuthSwitcher.tsx` | edit | legacy dev auth 표시 제거 | client-controlled identity header auth path 제거 | local dev는 bearer/OIDC test token 필요 | +| `frontend/src/app/prompt-studio/page.tsx` | edit | direct route 권한 차단 | provider-backed prompt test는 org secret을 소비하므로 admin-only | member private prompt draft는 backend에서 유지 | +| `backend/api/prompts.py` | edit | shared prompt/test API admin guard | BYOK provider와 prompt sharing의 권한 경계를 backend에서 강제 | 기존 member private prompt CRUD 보존 | +| `ARCHITECTURE.md` | edit | 현재 권한/에픽 경계 동기화 | foundation과 future epic을 혼동하지 않게 함 | 구현 이후 사실만 기록 | diff --git a/frontend/src/app/ai-hub/actions/page.test.tsx b/frontend/src/app/ai-hub/actions/page.test.tsx new file mode 100644 index 000000000..136dff8cf --- /dev/null +++ b/frontend/src/app/ai-hub/actions/page.test.tsx @@ -0,0 +1,97 @@ +/* @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const apiClientMock = vi.hoisted(() => ({ + getBearerToken: vi.fn(() => null), + getCurrentUserId: vi.fn(() => 'testuser'), + getCurrentOrganizationId: vi.fn(() => 'org-local-dev'), + get: vi.fn(async (endpoint: string) => { + if (endpoint === '/api/mailbox-accounts') { + return { + items: [ + { + id: 2, + email_address: 'beta@example.com', + display_name: 'Beta', + is_default_reply: false, + is_active: true, + }, + ], + }; + } + return { + emails: [ + { + id: 7, + subject: '출시 일정 검토 요청', + snippet: 'Q2 출시 계획과 우선순위 조정 요청입니다.', + unread: true, + reply_count: 3, + }, + ], + }; + }), +})); + +vi.mock('@/lib/api-client', () => ({ apiClient: apiClientMock })); + +import AIHubActionsPage from './page'; + +async function flushAsyncWork() { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +describe('AIHubActionsPage', () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + window.localStorage.clear(); + vi.clearAllMocks(); + }); + + it('does not duplicate queued email items in the backlog list', async () => { + window.localStorage.setItem('naruon.executionQueue.org-local-dev.testuser', JSON.stringify([ + { + id: 70, + sourceMailboxAccountId: 2, + sourceEmailId: 7, + sourceSnippet: 'Q2 출시 계획과 우선순위 조정 요청입니다.', + title: '출시 일정 검토 요청', + sender: '김지현 PM', + status: 'queued', + createdAt: '2026-05-14T00:00:00.000Z', + updatedAt: '2026-05-14T00:00:00.000Z', + completedAt: null, + }, + ])); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render(); + }); + await flushAsyncWork(); + + const matches = container.textContent?.match(/출시 일정 검토 요청/g) ?? []; + expect(matches).toHaveLength(1); + expect(container.textContent).toContain('스와이프로 담긴 실행 항목'); + expect(container.textContent).toContain('Beta'); + expect(container.textContent).toContain('Q2 출시 계획과 우선순위 조정 요청입니다.'); + }); +}); diff --git a/frontend/src/app/ai-hub/actions/page.tsx b/frontend/src/app/ai-hub/actions/page.tsx new file mode 100644 index 000000000..91403ce60 --- /dev/null +++ b/frontend/src/app/ai-hub/actions/page.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; + +import { apiClient } from '@/lib/api-client'; +import { listExecutionQueue, refreshExecutionQueue, subscribeExecutionQueue, type ExecutionQueueItem } from '@/lib/execution-queue'; +import { type WorkspaceInsightEmail } from '@/lib/workspace-insights'; +import { InsightCard } from '@/components/InsightCard'; +import { CheckCircle2, CalendarDays, Repeat } from 'lucide-react'; + +interface MailboxAccountSummary { + id: number; + email_address: string; + display_name: string | null; + is_default_reply: boolean; + is_active: boolean; +} + +function buildExecutionBacklog(emails: WorkspaceInsightEmail[]) { + return emails + .filter((email) => `${email.subject || ''} ${email.snippet}`.match(/검토|요청|회의|일정|확인|조정/)) + .slice(0, 6) + .map((email) => ({ + id: email.id, + title: email.subject || '(제목 없음)', + note: email.snippet, + followUp: (email.reply_count || 0) > 1 ? '조율 후 답장' : '답장 또는 일정 확인', + })); +} + +export default function AIHubActionsPage() { + const [emails, setEmails] = useState([]); + const [queueItems, setQueueItems] = useState([]); + const [mailboxAccounts, setMailboxAccounts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const syncQueue = () => setQueueItems(listExecutionQueue()); + syncQueue(); + void refreshExecutionQueue().then(setQueueItems); + return subscribeExecutionQueue(syncQueue); + }, []); + + useEffect(() => { + let active = true; + const loadMailboxAccounts = async () => { + try { + const data = await apiClient.get<{ items: MailboxAccountSummary[] }>('/api/mailbox-accounts'); + if (!active) return; + setMailboxAccounts(data.items || []); + } catch { + if (!active) return; + setMailboxAccounts([]); + } + }; + void loadMailboxAccounts(); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + let active = true; + const loadEmails = async () => { + try { + const data = await apiClient.get<{ emails: WorkspaceInsightEmail[] }>('/api/emails?limit=24'); + if (!active) return; + setEmails(data.emails || []); + } catch (err: unknown) { + if (!active) return; + setError((err as Error).message || '실행 항목을 불러오지 못했습니다.'); + } finally { + if (active) setLoading(false); + } + }; + void loadEmails(); + return () => { + active = false; + }; + }, []); + + const backlog = useMemo(() => { + const queuedIds = new Set(queueItems.filter((item) => item.status === 'queued').map((item) => item.sourceEmailId).filter((value): value is number => value !== null)); + return buildExecutionBacklog(emails).filter((item) => !queuedIds.has(item.id)); + }, [emails, queueItems]); + const queuedItems = useMemo(() => queueItems.filter((item) => item.status === 'queued'), [queueItems]); + const mailboxLabel = (mailboxAccountId?: number | null) => { + if (!mailboxAccountId) return null; + const account = mailboxAccounts.find((item) => item.id === mailboxAccountId); + return account ? (account.display_name || account.email_address) : null; + }; + + return ( +
+
+

Workspace / Actions

+

실행 항목

+

메일에서 바로 이어지는 답장, 일정, 후속 조치를 실행 보드로 넘깁니다.

+
+ +
+ } loading={loading} error={error} empty={!loading && queuedItems.length + backlog.length === 0} emptyMessage="실행으로 넘길 메일이 없습니다."> +
    + {queuedItems.map((item) => ( +
  • +
    +

    {item.title}

    + {mailboxLabel(item.sourceMailboxAccountId) ? ( + + {mailboxLabel(item.sourceMailboxAccountId)} + + ) : null} +
    +

    {item.sender}

    + {item.sourceSnippet ?

    {item.sourceSnippet}

    : null} +

    스와이프로 담긴 실행 항목

    +
  • + ))} + {backlog.map((item) => ( +
  • +

    {item.title}

    +

    {item.note}

    +

    다음 행동: {item.followUp}

    +
  • + ))} +
+
+ +
+
+ } loading={loading} error={error} empty={false}> +

일정 언급 메일은 답장 전에 캘린더 후보로 보냅니다. 모바일에서는 메일 스와이프로 바로 실행 보드에 담을 수 있게 연결합니다.

+
+
+ } loading={loading} error={error} empty={false}> +

검토 → 판단 → 실행의 순서를 유지해 메일 읽기와 후속 작업 관리가 분리되지 않도록 구성합니다.

+
+
+
+
+ ); +} diff --git a/frontend/src/app/ai-hub/context/page.test.tsx b/frontend/src/app/ai-hub/context/page.test.tsx new file mode 100644 index 000000000..483ef8e70 --- /dev/null +++ b/frontend/src/app/ai-hub/context/page.test.tsx @@ -0,0 +1,65 @@ +/* @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const apiClientMock = vi.hoisted(() => ({ + get: vi.fn(async () => ({ emails: [] })), + post: vi.fn(async () => ({ + results: [ + { + id: 7, + subject: 'launch topic', + snippet: '검색어와 일치하는 출시 맥락입니다.', + unread: true, + reply_count: 2, + }, + ], + })), +})); + +vi.mock('@/lib/api-client', () => ({ apiClient: apiClientMock })); + +import AIHubContextPage from './page'; + +async function flushAsyncWork() { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +describe('AIHubContextPage', () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + window.history.replaceState({}, '', '/'); + vi.clearAllMocks(); + }); + + it('uses the header search query to load filtered context evidence', async () => { + window.history.pushState({}, '', '/ai-hub/context?q=launch'); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render(); + }); + await flushAsyncWork(); + + expect(apiClientMock.get).not.toHaveBeenCalledWith('/api/emails?limit=24'); + expect(apiClientMock.post).toHaveBeenCalledWith('/api/search', { query: 'launch' }); + expect(container.textContent).toContain('검색어: launch'); + expect(container.textContent).toContain('launch topic'); + }); +}); diff --git a/frontend/src/app/ai-hub/context/page.tsx b/frontend/src/app/ai-hub/context/page.tsx new file mode 100644 index 000000000..887f0d2dd --- /dev/null +++ b/frontend/src/app/ai-hub/context/page.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; + +import { apiClient } from '@/lib/api-client'; +import { deriveWorkspaceInsights, type WorkspaceInsightEmail } from '@/lib/workspace-insights'; +import { InsightCard } from '@/components/InsightCard'; +import { Network, Users, FileText } from 'lucide-react'; + +export default function AIHubContextPage() { + const [emails, setEmails] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const searchQuery = typeof window === 'undefined' + ? '' + : (new URLSearchParams(window.location.search).get('q') || '').trim(); + + useEffect(() => { + let active = true; + const loadEmails = async () => { + try { + const data = searchQuery + ? await apiClient.post<{ results: WorkspaceInsightEmail[] }>('/api/search', { query: searchQuery }) + : await apiClient.get<{ emails: WorkspaceInsightEmail[] }>('/api/emails?limit=24'); + if (!active) return; + setEmails('results' in data ? data.results || [] : data.emails || []); + } catch (err: unknown) { + if (!active) return; + setError((err as Error).message || '맥락 데이터를 불러오지 못했습니다.'); + } finally { + if (active) setLoading(false); + } + }; + void loadEmails(); + return () => { + active = false; + }; + }, [searchQuery]); + + const insights = useMemo(() => deriveWorkspaceInsights(emails), [emails]); + + return ( +
+
+

Workspace / Context

+

맥락 종합

+

최근 메일에서 지금 검토, 조율, 실행으로 이어질 흐름을 한 번에 훑습니다.

+ {searchQuery ?

검색어: {searchQuery}

: null} +
+ +
+ } loading={loading} error={error} empty={!loading && emails.length === 0}> +

우선 검토 메일 {insights.judgmentCount}건이 현재 맥락의 시작점입니다.

+
+ } loading={loading} error={error} empty={!loading && emails.length === 0}> +

여러 번 왕복된 대화 {insights.coordinationCount}건을 우선 조율 대상으로 봅니다.

+
+ } loading={loading} error={error} empty={!loading && emails.length === 0}> +
    + {emails.slice(0, 4).map((email) => ( +
  • +

    {email.subject || '(제목 없음)'}

    +

    {email.snippet}

    +
  • + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/app/ai-hub/decisions/page.tsx b/frontend/src/app/ai-hub/decisions/page.tsx new file mode 100644 index 000000000..18be8b142 --- /dev/null +++ b/frontend/src/app/ai-hub/decisions/page.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; + +import { apiClient } from '@/lib/api-client'; +import { deriveWorkspaceInsights, type WorkspaceInsightEmail } from '@/lib/workspace-insights'; +import { InsightCard } from '@/components/InsightCard'; +import { Target, AlertTriangle } from 'lucide-react'; + +function buildDecisionAgenda(emails: WorkspaceInsightEmail[], judgmentCount: number, coordinationCount: number) { + const items = [ + `우선 검토 ${judgmentCount}건 중 오늘 답을 내야 하는 메일을 먼저 고릅니다.`, + `조율 필요 ${coordinationCount}건은 담당자/일정/답장 순서를 명확히 해야 합니다.`, + ]; + + const scheduled = emails.find((email) => `${email.subject || ''} ${email.snippet}`.includes('일정')); + if (scheduled) { + items.push(`일정 언급 메일 '${scheduled.subject || '(제목 없음)'}'의 캘린더 반영 여부를 결정합니다.`); + } + + return items; +} + +export default function AIHubDecisionsPage() { + const [emails, setEmails] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + const loadEmails = async () => { + try { + const data = await apiClient.get<{ emails: WorkspaceInsightEmail[] }>('/api/emails?limit=24'); + if (!active) return; + setEmails(data.emails || []); + } catch (err: unknown) { + if (!active) return; + setError((err as Error).message || '판단 포인트를 불러오지 못했습니다.'); + } finally { + if (active) setLoading(false); + } + }; + void loadEmails(); + return () => { + active = false; + }; + }, []); + + const insights = useMemo(() => deriveWorkspaceInsights(emails), [emails]); + const agenda = useMemo(() => buildDecisionAgenda(emails, insights.judgmentCount, insights.coordinationCount), [emails, insights]); + + return ( +
+
+

Workspace / Decisions

+

판단 포인트

+

답장, 일정, 후속 실행으로 넘어가기 전에 먼저 결정해야 하는 쟁점을 추립니다.

+
+ + } loading={loading} error={error} empty={!loading && agenda.length === 0}> +
    + {agenda.map((item) => ( +
  • {item}
  • + ))} +
+
+ + } loading={loading} error={error} empty={!loading && emails.length === 0}> +

다자간 조율 메일은 담당 주체와 마감이 흐려지기 쉽습니다. 메일 스레드를 실행 항목으로 밀어 넣기 전에 담당자와 일정, 필요한 답장 순서를 확정하세요.

+
+
+ ); +} diff --git a/frontend/src/app/ai-hub/page.tsx b/frontend/src/app/ai-hub/page.tsx index 604deee9d..12998d2c8 100644 --- a/frontend/src/app/ai-hub/page.tsx +++ b/frontend/src/app/ai-hub/page.tsx @@ -1,60 +1,56 @@ 'use client'; -import React, { useEffect, useState } from 'react'; -import { apiClient } from '@/lib/api-client'; -import { InsightCard } from '@/components/InsightCard'; -import { Network, Sparkles, BookOpen } from 'lucide-react'; +import Link from 'next/link'; -export default function AIHubPage() { - const [prompts, setPrompts] = useState<{ id: number; title: string; description?: string }[]>([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); +import { Badge } from '@/components/ui/badge'; +import { InsightCard } from '@/components/InsightCard'; +import { CheckCircle2, Network, Target } from 'lucide-react'; - useEffect(() => { - const loadData = async () => { - try { - const data = await apiClient.get<{ id: number; title: string; description?: string }[]>('/api/prompts'); - setPrompts(data); - } catch (err: unknown) { - setError(((err as Error).message || '') || "데이터를 불러오는 데 실패했습니다."); - } finally { - setLoading(false); - } - }; - loadData(); - }, []); +const workspaceSections = [ + { + href: '/ai-hub/context', + title: '맥락 종합', + description: '관련 메일, 관계, 일정 언급을 한 번에 묶어 지금 어떤 흐름이 열려 있는지 빠르게 훑습니다.', + icon: , + badge: 'CONTEXT', + }, + { + href: '/ai-hub/decisions', + title: '판단 포인트', + description: '무엇을 먼저 결정해야 하는지, 어떤 메일이 일정/조율/답장을 막고 있는지를 정리합니다.', + icon: , + badge: 'DECISIONS', + }, + { + href: '/ai-hub/actions', + title: '실행 항목', + description: '메일에서 바로 후속 작업과 일정 반영 후보를 추려 실행 보드로 넘깁니다.', + icon: , + badge: 'ACTIONS', + }, +]; +export default function AIHubPage() { return ( -
-
-

- - AI Hub -

-

최근 인사이트와 저장된 프롬프트를 확인하세요.

-
+
+
+

Workspace overview

+

맥락 워크스페이스

+

AI를 전면에 내세우기보다, 메일을 읽고 판단하고 실행하는 흐름을 세 개의 작업면으로 나눠 정리합니다.

+
-
- } - loading={loading} - error={error} - empty={!loading && prompts.length === 0} - emptyMessage="저장된 프롬프트나 최근 요약이 없습니다." - > -
- {prompts.map(p => ( -
-

- - {p.title} -

-

{p.description || "설명 없음"}

+
+ {workspaceSections.map((section) => ( + + +
+ {section.badge} +

{section.description}

+

열기 →

- ))} -
- + + + ))}
); diff --git a/frontend/src/app/all/page.tsx b/frontend/src/app/all/page.tsx new file mode 100644 index 000000000..6ea21d877 --- /dev/null +++ b/frontend/src/app/all/page.tsx @@ -0,0 +1,5 @@ +import { WorkspacePlaceholderPage } from '@/components/WorkspacePlaceholderPage'; + +export default function AllMailPage() { + return ; +} diff --git a/frontend/src/app/compose/ComposePageClient.test.tsx b/frontend/src/app/compose/ComposePageClient.test.tsx new file mode 100644 index 000000000..e7863237c --- /dev/null +++ b/frontend/src/app/compose/ComposePageClient.test.tsx @@ -0,0 +1,115 @@ +/* @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const apiClientMock = vi.hoisted(() => ({ + get: vi.fn(async (endpoint: string) => { + if (endpoint === '/api/mailbox-accounts') { + return { + items: [ + { + id: 1, + email_address: 'alpha@example.com', + display_name: 'Alpha', + is_default_reply: true, + is_active: true, + }, + { + id: 2, + email_address: 'beta@example.com', + display_name: 'Beta', + is_default_reply: false, + is_active: true, + }, + ], + }; + } + if (endpoint === '/api/emails/7') { + return { + id: 7, + mailbox_account_id: 2, + message_id: '', + thread_id: 'thread-q2', + sender: '김지현 PM', + reply_to: 'jihyun@naruon.ai', + recipients: 'user@naruon.ai', + subject: 'Q2 출시 계획 및 우선순위 조정', + date: '2026-05-11T09:30:00Z', + body: '본문', + }; + } + throw new Error(`Unexpected GET ${endpoint}`); + }), + post: vi.fn(async () => ({ simulated: true })), +})); + +vi.mock('@/lib/api-client', () => ({ apiClient: apiClientMock })); + +import { ComposePageClient } from './ComposePageClient'; + +async function flushAsyncWork() { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +async function updateInputValue(element: HTMLInputElement | HTMLTextAreaElement, value: string) { + const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + await act(async () => { + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + }); +} + +describe('ComposePageClient', () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + root?.unmount(); + root = null; + container?.remove(); + container = null; + vi.clearAllMocks(); + }); + + it('loads linked mailbox accounts and sends with the selected mailbox account id', async () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + + expect(container.textContent).toContain('회신 계정'); + expect(container.textContent).toContain('Alpha'); + + const select = container.querySelector('select'); + expect(select?.value).toBe('2'); + + await act(async () => { + select!.value = '2'; + select!.dispatchEvent(new Event('change', { bubbles: true })); + }); + + await updateInputValue(container.querySelector('input[aria-label="받는 사람"]') as HTMLInputElement, 'beta@example.com'); + await updateInputValue(container.querySelector('input[aria-label="메일 제목"]') as HTMLInputElement, 'Re: Edited subject'); + await updateInputValue(container.querySelector('textarea[aria-label="메일 본문"]') as HTMLTextAreaElement, '답장 본문'); + await flushAsyncWork(); + + const sendButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('메일 보내기')); + await act(async () => { + sendButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(apiClientMock.post).toHaveBeenCalledWith('/api/emails/send', expect.objectContaining({ mailbox_account_id: 2 })); + }); +}); diff --git a/frontend/src/app/compose/ComposePageClient.tsx b/frontend/src/app/compose/ComposePageClient.tsx new file mode 100644 index 000000000..46bc0f391 --- /dev/null +++ b/frontend/src/app/compose/ComposePageClient.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; + +import { apiClient } from '@/lib/api-client'; +import { buildReplyPayload, type ThreadEmailData } from '@/lib/email-threading'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Mail, PenLine, SendHorizonal } from 'lucide-react'; + +interface MailboxAccountOption { + id: number; + email_address: string; + display_name: string | null; + is_default_reply: boolean; + is_active: boolean; +} + +export function ComposePageClient({ emailId }: { emailId?: string }) { + const [mailboxAccounts, setMailboxAccounts] = useState([]); + const [selectedMailboxAccountId, setSelectedMailboxAccountId] = useState(null); + const [sourceEmail, setSourceEmail] = useState(null); + const [to, setTo] = useState(''); + const [subject, setSubject] = useState(''); + const [body, setBody] = useState(''); + const [instruction, setInstruction] = useState('정중하게 답장 초안을 작성해줘'); + const [loading, setLoading] = useState(false); + const [drafting, setDrafting] = useState(false); + const [sending, setSending] = useState(false); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + const loadMailboxAccounts = async () => { + try { + const data = await apiClient.get<{ items: MailboxAccountOption[] }>('/api/mailbox-accounts'); + if (!active) return; + setMailboxAccounts(data.items || []); + const defaultAccount = data.items.find((item) => item.is_default_reply && item.is_active) || data.items[0] || null; + setSelectedMailboxAccountId(defaultAccount?.id ?? null); + } catch { + if (!active) return; + setMailboxAccounts([]); + } + }; + void loadMailboxAccounts(); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + if (!emailId) return; + let active = true; + const loadSourceEmail = async () => { + setLoading(true); + setError(null); + try { + const email = await apiClient.get(`/api/emails/${emailId}`); + if (!active) return; + setSourceEmail(email); + setTo(email.reply_to || email.sender || ''); + setSubject(email.subject ? `Re: ${email.subject}` : 'Re: (제목 없음)'); + if (email.mailbox_account_id) { + setSelectedMailboxAccountId(email.mailbox_account_id); + } + } catch (err: unknown) { + if (!active) return; + setError((err as Error).message || '답장 대상 메일을 불러오지 못했습니다.'); + } finally { + if (active) setLoading(false); + } + }; + void loadSourceEmail(); + return () => { + active = false; + }; + }, [emailId]); + + const hint = useMemo(() => { + if (sourceEmail) { + return `${sourceEmail.subject || '(제목 없음)'} 메일을 기준으로 답장 흐름을 이어갑니다.`; + } + return '새 메일을 작성하거나, 받은편지함에서 선택한 메일의 답장 초안을 이어서 작성할 수 있습니다.'; + }, [sourceEmail]); + + const handleDraft = async () => { + if (!sourceEmail) { + setStatus('선택된 원본 메일이 없어서 일반 메일 작성 모드로 유지합니다.'); + return; + } + setDrafting(true); + setError(null); + setStatus(null); + try { + const draft = await apiClient.post<{ draft: string }>('/api/llm/draft', { + email_body: sourceEmail.body, + instruction, + }); + setBody(draft.draft || ''); + } catch (err: unknown) { + setError((err as Error).message || '답장 초안을 만들지 못했습니다.'); + } finally { + setDrafting(false); + } + }; + + const handleSend = async () => { + if (!to || !subject || !body) { + setError('받는 사람, 제목, 본문을 모두 입력하세요.'); + return; + } + setSending(true); + setError(null); + setStatus(null); + try { + const payload = { + ...(sourceEmail + ? buildReplyPayload(sourceEmail, body, { to, subject }) + : { to, subject, body }), + ...(selectedMailboxAccountId ? { mailbox_account_id: selectedMailboxAccountId } : {}), + }; + const result = await apiClient.post<{ simulated?: boolean }>('/api/emails/send', payload); + setStatus(result.simulated ? '개발 모드에서 메일 전송을 시뮬레이션했습니다.' : '메일을 전송했습니다.'); + if (!sourceEmail) { + setBody(''); + } + } catch (err: unknown) { + setError((err as Error).message || '메일 전송에 실패했습니다.'); + } finally { + setSending(false); + } + }; + + return ( +
+
+

Mail / Compose

+

메일 작성

+

{hint}

+
+ +
+ {loading ?

원본 메일을 불러오는 중입니다...

: null} + {error ?
{error}
: null} + {status ?
{status}
: null} + +
+ {mailboxAccounts.length > 0 ? ( +
+ + +
+ ) : null} + setTo(event.target.value)} /> + setSubject(event.target.value)} /> +