Skip to content

mailbox-aware OIDC 인증 기반 및 메일함 스코프 확장 - #199

Closed
seonghobae wants to merge 12 commits into
masterfrom
feature/oidc-integration-20260513
Closed

mailbox-aware OIDC 인증 기반 및 메일함 스코프 확장#199
seonghobae wants to merge 12 commits into
masterfrom
feature/oidc-integration-20260513

Conversation

@seonghobae

@seonghobae seonghobae commented May 14, 2026

Copy link
Copy Markdown
Contributor

No linked issue.

요약

  • mailbox-aware OIDC/auth 기반과 개인 메일함 계정 레지스트리를 추가했습니다.
  • owner/mailbox 범위의 email/search/thread API와 execution item queue 기반을 도입했습니다.
  • AI Hub, settings, login, compose 화면을 새 인증/메일함 흐름에 맞춰 갱신했습니다.
  • IMAP/POP worker 기반과 런타임 설정 기반 dev header gating을 추가했습니다.

검증

  • Backend pytest: 192 passed, 2 skipped
  • Frontend tests/lint/build/e2e: green
  • black/ruff/diff checks: clean
  • Security review: no blockers reported

보안/운영 메모

  • SMTP/IMAP/POP3 연결 시 SSRF/DNS rebinding 방어를 위해 connect-time DNS 검증, pinned IP connect, original-hostname TLS verification 기반을 추가했습니다.
  • dev header 경로는 런타임 설정으로 제한되어 운영 환경에서 임의 활성화되지 않도록 했습니다.
  • 실행 item queue는 요청 경로와 작업 실행을 분리하기 위한 기반입니다. consumer timeout/backpressure/retry 정책은 후속 작업에서 확정합니다.
  • UI 변경 범위는 AI Hub, settings, login, compose 및 메일함 기반 탐색 흐름입니다.
  • 이번 PR은 OIDC 연동 기반과 메일함 범위 모델을 마련하는 변경이며, 완전한 내장 OIDC provider/session 구현이나 실제 IMAP/POP 영속 수집 완성은 포함하지 않습니다.

후속 작업

  • 내장 OIDC provider/session 플로우 완성
  • 실제 IMAP/POP ingestion 영속화 및 재시도/백프레셔 정책 구현
  • production IdP/Casdoor 연동 전략과 secret store 환경변수 문서화
  • 메일함별 권한/검색/스레드 API에 대한 추가 운영 시나리오 검증

Summary by CodeRabbit

  • New Features

    • Manage multiple mailbox accounts and choose a default reply account.
    • Personal execution queue: enqueue, view, and complete email execution items.
    • OIDC/bearer-token login support and optional manual bearer token entry in UI.
    • Organization-scoped provider & shared prompt support.
    • Mailbox-scoped search and inbox filtering.
    • Dashboard reorganized into Context, Decisions, and Actions workspaces.
  • Bug Fixes

    • Calendar sync now uses server-side stored credentials.
    • Stronger mail-server validation to block unsafe/internal hosts.
    • Mobile navigation and scrolling improvements.
  • Chores

    • .env.example updated with AUTH_MODE, TRUST_DEV_HEADERS, and LEGACY_EMAIL_OWNER_USER_ID.

Review Change Stack

Add mailbox-scoped dashboard, auth, execution queue, and mail-account infrastructure while closing trusted-header and outbound mail SSRF gaps for the staged OIDC integration path.
@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key(s) in object: 'version'
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
📝 Walkthrough

Walkthrough

This PR switches backend auth to bearer/OIDC tokens, adds mailbox-account ownership and routing, introduces persisted ExecutionItem queue APIs and frontend queue UX, hardens mail-server validation and mail sync/sending plumbing, updates DB schemas/migrations, and adds many frontend pages and tests reflecting mailbox-aware flows.

Changes

Core auth, mailbox, models & workers

Layer / File(s) Summary
Auth and runtime config
backend/api/auth.py, backend/core/config.py, backend/api/runtime_config.py, backend/tests/auth_helpers.py, backend/tests/conftest.py
Adds AUTH_MODE and OIDC settings; implements JWT/JWKS decoding, cached JWKS client, updates get_auth_context/get_current_user to use bearer token, and exposes runtime flags including manual_bearer_login_enabled. Test helpers and autouse fixture added.
DB models and bootstrap/backfill
backend/db/models.py, backend/scripts/bootstrap_db.py
Adds MailboxAccount and ExecutionItem models, scopes uniqueness to organization/user/mailbox, adds Email.user_id and mailbox_account_id, and parameterizes bootstrap backfill with legacy IDs.
Mail server security & routing
backend/services/mail_server_security.py, backend/services/mailbox_routing.py
Adds host/port validation, DNS resolution safety checks, MailServerConnectTarget, and mailbox-account resolution helpers for routing imports/sends.
Email client & workers
backend/services/email_client.py, backend/services/imap_worker.py, backend/services/pop3_worker.py
Send path now validates/resolves SMTP target and uses pinned-connect clients; IMAP/POP3 workers reworked to sync per MailboxAccount with pinned SSL clients and DNS re-resolution.
Import fixtures & bootstrap wiring
backend/import_fixtures.py, backend/scripts/import_fixtures.py
ZIP/.eml importer resolves mailbox_account_id, upgrades legacy NULL-mailbox rows when matched, and uses conditional upsert conflict targeting.
App wiring & deps
backend/main.py, backend/requirements.txt
Wires new routers, starts/stops IMAP+POP3 workers in lifespan, and adds PyJWT dependency.

APIs and services

Layer / File(s) Summary
Emails API & mailbox scope
backend/api/emails.py, backend/api/mailbox_scope.py
Add mailbox_account_id to list/detail/thread responses, enforce ownership via require_owned_mailbox_account, and select SMTP creds from MailboxAccount (or TenantConfig fallback).
Mailbox accounts API
backend/api/mailbox_accounts.py
CRUD endpoints for mailbox accounts with sanitization, host/port validation, default-reply enforcement, secret masking, and error mapping (409/503).
Execution items API
backend/api/execution_items.py
New router providing list, queue-from-email (idempotent/upsert), and patch-to-done endpoints with schemas.
Search, network, prompts
backend/api/search.py, backend/api/network.py, backend/api/prompts.py
Mailbox-scoped search and reply-count subquery, network graph filtered by Email.user_id, prompts refactored to AuthContext/org-scoped behavior and workspace-admin enforcement for prompt testing.
LLM providers & runtime
backend/api/llm_providers.py, backend/api/llm.py
Providers scoped to organization with require_provider_admin gate and IntegrityError handling; LLM endpoints preserve behavior and format changes.
Calendar sync
backend/api/calendar.py
Sync endpoint now depends on server-side credential lookup (get_calendar_credentials_for_user) and returns 503 when missing; client user_token is ignored.

Frontend pages, client, and execution queue

Layer / File(s) Summary
Api client & runtime-config
frontend/src/lib/api-client.ts, frontend/src/lib/runtime-config.ts
Refactor ApiClient to use bearer token in localStorage, decode claims, gate dev-header emission via runtime-config; add runtime-config client and tests; add manual bearer login UI.
Execution queue & UI integration
frontend/src/lib/execution-queue.ts, frontend/src/components/EmailList.tsx, frontend/src/app/ai-hub/actions/page.tsx, frontend/src/components/EmailDetail.tsx
Client execution-queue persisted per-user/org or volatile when authenticated; EmailList supports swipe enqueue/complete, mailbox filter, and integrates ExecutionItem APIs; AI Hub actions page and EmailDetail adjusted for mailbox context.
Pages and components
many files under frontend/src/app/* and frontend/src/components/*
Add/modify numerous pages (ai-hub context/decisions/actions, compose client/server, compose tests, labels/projects/all/sent/drafts/starred placeholders), DashboardLayout insight and scroll restoration, Settings mailbox accounts UI, PromptStudio access gating, and many tests.
Tests & e2e helpers
many frontend/tests/*, frontend/tests/e2e/helpers.ts
Extensive Vitest suite additions and Playwright e2e mocks for runtime-config and execution-items, plus many component tests updated for mailbox-aware behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

"A rabbit hops with a tiny cheer,
Tokens signed, and inboxes clear.
Mailboxes routed, queues take flight,
Workers hum through day and night.
Hooray — small hops, big change in sight!" 🐇✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/oidc-integration-20260513

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 0f73df3e42c3fc042b242150fe45e09e4df345de:

  • 3 required check(s) are not successful on 0f73df3.\n- Missing current-head CodeRabbit/coderabbitai evidence for 0f73df3.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 0f73df3e42c3fc042b242150fe45e09e4df345de:

  • 2 required check(s) are not successful on 0f73df3.\n- Missing current-head CodeRabbit/coderabbitai evidence for 0f73df3.\n

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 0f73df3e42c3fc042b242150fe45e09e4df345de:

  • 2 required check(s) are not successful on 0f73df3.\n- Missing current-head CodeRabbit/coderabbitai evidence for 0f73df3.\n

async with lifespan(FastAPI()):
raise RuntimeError("app failed")

imap_stop.assert_awaited_once()
Comment thread backend/tests/test_prompts_api.py Fixed
Comment thread backend/tests/test_prompts_api.py Fixed
@greptile-apps

greptile-apps Bot commented May 14, 2026

Copy link
Copy Markdown

Greptile Summary

mailbox-aware OIDC 인증 기반과 개인 메일함 계정 레지스트리를 추가하고, owner/mailbox 범위의 email/search/thread API 및 execution item queue를 도입하는 대규모 PR입니다. 전반적인 방향성은 명확하나, 이전 리뷰에서 지적된 다수의 미해결 항목이 아직 남아 있습니다.

  • 신규 버그: sanitize_email_body_for_response가 HTML 태그 없는 평문 이메일에서 decoded_body 대신 원본 body를 반환해 & 같은 HTML 엔티티가 그대로 노출됩니다.
  • 데이터 무결성: ExecutionItem.source_mailbox_account_id에 FK 제약이 없어 메일함 계정 삭제 시 고아 행이 남을 수 있습니다.
  • 인증/마이그레이션: hybrid 모드 dev-header 폴백 미구현, prompt_templates legacy 공유 프롬프트 backfill 부재, llm_providers 마이그레이션 guard 누락 등 이전 리뷰 항목이 다수 미해결 상태입니다.

Confidence Score: 3/5

이전 리뷰에서 지적된 여러 결함(SMTP TLS 분기 누락, 동기 DNS 블로킹, _clear_default_reply 경쟁 조건, hybrid 모드 폴백, prompt_templates 마이그레이션 누락 등)이 아직 수정되지 않아 운영 환경 배포 전 추가 작업이 필요합니다.

이번 리뷰에서 신규로 발견된 sanitize_email_body_for_responsereturn body 오류(평문 이메일 엔티티 미처리)가 실제 동작 결함입니다. 이전 리뷰 미해결 항목들, 특히 SMTP 포트 587 연결 실패, _clear_default_reply 경쟁 조건, prompt_templates legacy backfill 부재가 누적되어 있어 안정적인 운영 배포가 어렵습니다.

backend/api/emails.py (sanitize_email_body_for_response 반환값 오류), backend/services/email_client.py (SMTP TLS 분기), backend/scripts/bootstrap_db.py (마이그레이션 guard), backend/api/mailbox_accounts.py (_clear_default_reply 경쟁 조건)

Important Files Changed

Filename Overview
backend/api/auth.py 기존 헤더 기반 인증을 JWT Bearer 토큰 검증으로 교체. hybrid 모드가 oidc와 동일하게 동작(dev-header 폴백 미구현), "header" 리터럴은 타입에 남아있지만 처리 로직 없음.
backend/api/emails.py 메일함 범위 필터링 및 SMTP 발신 경로 추가. sanitize_email_body_for_response가 평문 이메일에서 HTML 엔티티를 미처리하는 버그 있음.
backend/api/execution_items.py 실행 아이템 CRUD API 신규 추가. 생성 경로에서 _snapshot_email_fields 헬퍼를 사용하지 않고 인라인으로 스냅샷 로직 중복.
backend/api/mailbox_accounts.py 메일함 계정 CRUD API 신규 추가. _clear_default_reply 경쟁 조건(DB 유일성 미보장), 동기 DNS 조회로 이벤트 루프 블로킹.
backend/services/email_client.py _PinnedSMTP SSRF 방어 로직 추가. use_tls=True 하드코딩으로 포트 587/25/2525 연결 실패, self.loop aiosmtplib 3.x 호환성 미검증.
backend/db/models.py MailboxAccount, ExecutionItem 모델 신규 추가 및 스키마 확장. ExecutionItem.source_mailbox_account_id FK 누락.
backend/scripts/bootstrap_db.py 스키마 마이그레이션 확장. llm_providers 레거시 guard 부재, prompt_templates 기존 공유 프롬프트 backfill 미구현.
backend/api/llm_providers.py 조직 범위 기반 LLM provider 관리로 리팩터링. platform_adminorganization_id 없을 때 403 반환되는 이슈.
frontend/src/lib/api-client.ts X-User-Id 헤더 기반에서 Bearer JWT 기반 인증으로 전환. 전반적으로 안전함.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Client Request] --> B{Authorization header present?}
    B -- No --> C[401 Authentication Required]
    B -- Yes --> D{AUTH_MODE}
    D -- oidc or hybrid --> E[jwt.get_unverified_header]
    E --> F{OIDC_JWKS_URL set?}
    F -- Yes --> G[RS256: asyncio.to_thread JWKS fetch]
    F -- No --> H[HS256: shared secret]
    G --> I[jwt.decode algorithms RS256]
    H --> J[jwt.decode algorithms HS256]
    I --> K[build AuthContext from claims]
    J --> K
    K --> L[API handler]
    L --> M{mailbox_account_id filter?}
    M -- Yes --> N[require_owned_mailbox_account]
    M -- No --> O[user_id scoped query]
    N --> O
    O --> P[Return response]
Loading

Reviews (11): Last reviewed commit: "fix: fail closed on missing OIDC verifie..." | Re-trigger Greptile

Comment thread backend/api/search.py
Comment on lines +55 to +58

def build_reply_counts_subquery(
current_user: str, mailbox_account_id: int | None = None
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 메일함 필터 predicate가 mailbox_account_id IS NULL인 행도 포함

mailbox_scope_predicateOR mailbox_account_id IS NULL 조건을 추가하기 때문에, mailbox_account_id=1로 검색하면 계정 1의 메일과 레거시(소유 계정 미할당) 메일이 함께 반환됩니다. 반면 GET /api/emails?mailbox_account_id=1 엔드포인트는 동일 파라미터에 등호 조건만 사용하므로, 두 API의 필터링 결과가 일치하지 않습니다. 사용자가 특정 메일함으로 검색·필터링할 때 다른 메일함의 레거시 메일이 섞여 나올 수 있습니다.

Comment on lines +50 to +65
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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 async 핸들러 내 blocking DNS 조회로 이벤트 루프 블로킹

_resolve_safe_mail_server_ips는 표준 라이브러리의 socket.getaddrinfo를 동기적으로 호출하는데, 이 함수는 호스트명 해석 결과가 나올 때까지 블로킹됩니다. 이 함수는 validate_mail_server_host_validate_mailbox_payload를 통해 create_mailbox_account·update_mailbox_account async 핸들러에서 직접 호출됩니다. DNS 응답이 수백 ms ~ 수 초 지연되는 경우 전체 asyncio 이벤트 루프가 정지되어 다른 모든 요청이 응답을 받지 못하게 됩니다. asyncio.to_thread(socket.getaddrinfo, ...) 패턴이나 비동기 DNS 라이브러리(aiodns 등)로 감싸야 합니다.

Comment on lines +168 to +185

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 _clear_default_reply 경쟁 조건 — DB 수준 유일성 보장 없음

_clear_default_reply는 해당 유저의 모든 계정을 SELECT한 뒤 Python 레이어에서 is_default_reply = False로 바꾸고, 이후 새 계정에 is_default_reply = True를 설정합니다. 두 개의 요청이 동시에 도달하면 양쪽 다 clear를 완료한 후 각자의 계정을 default로 설정해 커밋할 수 있어, 두 계정이 동시에 is_default_reply = True인 상태가 됩니다. DB 스키마에 partial unique index ON mailbox_accounts (user_id) WHERE is_default_reply = true를 추가하거나, UPDATE ... SET is_default_reply = FALSE WHERE user_id = :uid 단일 문으로 교체하면 원자적으로 처리할 수 있습니다.

Comment on lines +106 to +122
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 신규 ExecutionItem 생성 시 _snapshot_email_fields 미사용으로 로직 중복

_snapshot_email_fields 헬퍼가 스냅샷 필드 설정 책임을 집중하기 위해 정의되어 있음에도, 새 아이템 생성 경로에서는 같은 로직(email.body[:200] + "..." 등)을 직접 인라인으로 반복하고 있습니다. 이후 snippet 길이나 필드 목록이 변경될 경우 두 곳을 모두 수정해야 하는데 한 곳을 빠뜨리기 쉽습니다. 새 ExecutionItem 생성 후 _snapshot_email_fields(item, email)을 호출하는 방식으로 통일하는 것을 권장합니다.

Suggested change
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)
else:
item = ExecutionItem(
user_id=auth_context.user_id,
organization_id=auth_context.organization_id,
workspace_id=auth_context.workspace_id,
status="queued",
created_at=now,
updated_at=now,
)
_snapshot_email_fields(item, email)
db.add(item)

Comment thread backend/api/auth.py
@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 0f73df3e42c3fc042b242150fe45e09e4df345de:

  • 8 unresolved current review thread(s) remain.\n- 2 required check(s) are not successful on 0f73df3.\n- Missing current-head CodeRabbit/coderabbitai evidence for 0f73df3.\n

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
backend/api/llm.py (1)

39-57: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Re-raise HTTPException before the catch-all.

The 400 raised on Line 44 and Line 76 is currently swallowed by except Exception and returned as 500, so a missing API key looks like an internal error instead of a client/config error.

Suggested fix
     except LLMServiceError:
         raise HTTPException(
             status_code=500,
             detail="An internal server error occurred while processing the request.",
         )
+    except HTTPException:
+        raise
     except Exception:
         raise HTTPException(
             status_code=500,
             detail="An internal server error occurred while processing the request.",
         )

Apply the same change to both endpoint handlers.

Also applies to: 71-92

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/llm.py` around lines 39 - 57, The HTTPException raised when
tenant_config is missing or missing openai_api_key is being swallowed by the
broad exception handlers; update the try/except blocks in the endpoint handlers
(the blocks around the tenant_config lookup and call to
extract_todos_and_summary) to explicitly re-raise HTTPException before the
generic excepts — i.e., add an except HTTPException: raise to pass through
client errors, keep the existing except LLMServiceError and the general except
Exception to handle server errors; make the same change in both handler blocks
that perform the tenant_config lookup and call extract_todos_and_summary.
backend/scripts/import_fixtures.py (1)

147-148: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rollback the session if the final commit fails.

If session.commit() raises here, the session stays in an aborted transaction state and the rest of the import run cannot recover cleanly. Wrap this in try/except, call rollback(), then re-raise or return.

Suggested fix
-        await session.commit()
+        try:
+            await session.commit()
+        except Exception:
+            await session.rollback()
+            raise
         logger.info(f"Finished processing {zip_path}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/scripts/import_fixtures.py` around lines 147 - 148, Wrap the final
await session.commit() and subsequent logger.info(f"Finished processing
{zip_path}") in a try/except block so that if commit raises you call await
session.rollback() and then re-raise the exception (or return) to avoid leaving
the session in an aborted state; specifically modify the block that uses
session.commit(), session.rollback(), and logger.info with zip_path so the
exception path always rolls back the session before propagating the error.
backend/tests/test_import_fixtures.py (1)

67-80: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Protect settings.LEGACY_EMAIL_OWNER_USER_ID overrides with guaranteed cleanup.

These tests mutate global settings but restore without finally. If an await/patch/assertion fails early, the leaked value can poison later tests.

Suggested fix pattern
-    previous_owner = settings.LEGACY_EMAIL_OWNER_USER_ID
-    settings.LEGACY_EMAIL_OWNER_USER_ID = "testuser"
-    ...
-    imported = await import_fixtures.import_eml_file(session, eml_file)
-    settings.LEGACY_EMAIL_OWNER_USER_ID = previous_owner
+    previous_owner = settings.LEGACY_EMAIL_OWNER_USER_ID
+    settings.LEGACY_EMAIL_OWNER_USER_ID = "testuser"
+    try:
+        ...
+        imported = await import_fixtures.import_eml_file(session, eml_file)
+    finally:
+        settings.LEGACY_EMAIL_OWNER_USER_ID = previous_owner

Also applies to: 136-147, 201-215, 234-237, 295-309, 385-398

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_import_fixtures.py` around lines 67 - 80, The test
overwrites the global settings.LEGACY_EMAIL_OWNER_USER_ID but restores it only
after the await/patch block, risking leakage if an exception occurs; wrap the
mutation in a try/finally (or use contextmanager) so the original value is
always restored—move settings.LEGACY_EMAIL_OWNER_USER_ID = previous_owner into a
finally block that encloses the patch.object(...)/await
import_fixtures.import_eml_file(...) section and apply the same pattern to the
other affected test blocks (lines referencing
settings.LEGACY_EMAIL_OWNER_USER_ID and import_fixtures.import_eml_file).
🟠 Major comments (20)
backend/db/models.py-281-293 (1)

281-293: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Enforce a single default mailbox per user

is_default_reply has no DB-level invariant, so multiple rows can become default for the same user. That makes default credential resolution ambiguous and risks sending from the wrong mailbox.

Please enforce this invariant (e.g., partial unique index on user_id WHERE is_default_reply = true plus migration/backfill handling).

backend/services/email_client.py-129-135 (1)

129-135: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid masking primary SMTP failures in cleanup

await smtp.quit() in finally can raise and overwrite the original failure from connect()/send_message(). Preserve the primary exception and treat quit errors as best-effort cleanup.

Suggested fix
     try:
         await smtp.connect()
         await smtp.send_message(message)
     finally:
         if smtp.is_connected:
-            await smtp.quit()
+            try:
+                await smtp.quit()
+            except Exception:
+                logger.warning("SMTP quit failed after send attempt", exc_info=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/email_client.py` around lines 129 - 135, The current finally
block may let await smtp.quit() raise and mask an earlier failure from await
smtp.connect() or await smtp.send_message(); modify the flow so the primary
exception is preserved: wrap connect/send_message in a try/except that captures
the primary exception, then in finally check smtp.is_connected and call await
smtp.quit() inside its own try/except that logs/suppresses any quit error (do
not re-raise), and after cleanup re-raise the captured primary exception if
present; reference the smtp object and its methods connect, send_message, quit,
is_connected, and the message variable to locate the code to change.
backend/services/mail_server_security.py-72-76 (1)

72-76: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

validate_mail_server_host currently accepts unresolved hosts

If DNS resolution fails, _resolve_safe_mail_server_ips returns an empty list and Line 75 doesn’t reject it. This allows invalid host config to persist and shifts failure to runtime.

Suggested fix
 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)
+    safe_ips = _resolve_safe_mail_server_ips(normalized_host, port)
+    if not safe_ips:
+        raise MailServerValidationError(f"{label} 서버 주소를 확인할 수 없습니다.")
     return normalized_host
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/mail_server_security.py` around lines 72 - 76, The function
validate_mail_server_host currently calls
_resolve_safe_mail_server_ips(normalized_host, port) but ignores its return
value so DNS failures (empty list) are allowed; modify validate_mail_server_host
to capture the returned IP list from _resolve_safe_mail_server_ips and if it is
empty raise a ValueError (or a specific config/validation exception used
elsewhere) with a clear message including prefix/label/host/port, otherwise
return the normalized_host; keep the initial syntax check via
_validate_mail_server_syntax_and_port and use the same exception type/pattern
used across the module for consistency.
backend/api/network.py-39-43 (1)

39-43: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bound limit to prevent oversized graph queries.

Line 39 accepts client-controlled limit without a hard cap. Very large values can degrade DB/query latency and in-memory graph construction.

Suggested patch
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
@@
-    limit: int = 500,
+    limit: int = Query(default=500, ge=1, le=2000),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/network.py` around lines 39 - 43, The endpoint currently accepts
a client-controlled limit parameter (signature with limit: int = 500, user_id:
str | None = None, db: AsyncSession = Depends(get_db), current_user: str =
Depends(get_current_user)) with no hard cap; add a MAX_LIMIT constant (e.g.,
MAX_NETWORK_LIMIT) and enforce it at the start of the handler by
validating/clamping the incoming limit (or returning an HTTPException/400 when
limit > MAX_LIMIT) to prevent oversized DB queries and in-memory graph
construction, and ensure the documentation/response uses the validated value
thereafter.
frontend/src/app/login/page.tsx-40-45 (1)

40-45: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mask and normalize bearer token input before persistence.

The token is entered as visible plain text and persisted without trimming, which increases credential exposure risk and can save invalid whitespace-padded tokens.

🔐 Proposed fix
-    if (!token.trim()) {
+    const normalizedToken = token.trim();
+    if (!normalizedToken) {
       setMessage('Bearer 토큰을 입력하세요.');
       return;
     }
-    apiClient.setBearerToken(token);
+    apiClient.setBearerToken(normalizedToken);
-            <Input value={token} onChange={(e) => setToken(e.target.value)} placeholder="eyJhbGciOi..." />
+            <Input
+              type="password"
+              autoComplete="off"
+              spellCheck={false}
+              value={token}
+              onChange={(e) => setToken(e.target.value)}
+              placeholder="eyJhbGciOi..."
+            />

Also applies to: 68-69

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app/login/page.tsx` around lines 40 - 45, Trim and normalize the
bearer token before passing it to apiClient.setBearerToken and avoid persisting
the raw token in component state; specifically, in the handler that currently
reads token and calls apiClient.setBearerToken(token) use a normalized value
(const normalized = token.trim()), call apiClient.setBearerToken(normalized),
then immediately clear the visible token input via setToken('') and, if you need
to show a saved indicator, store only a masked placeholder (e.g., '••••••') or a
boolean flag rather than the raw token; apply the same change to the other
occurrence around the code referenced at lines 68-69 so all token updates use
normalized values and do not retain plain-text tokens in state.
frontend/src/app/ai-hub/context/page.tsx-14-16 (1)

14-16: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use useSearchParams instead of window.location.search in render.

Reading window.location.search during render causes hydration mismatches in App Router pages—the server renders window as undefined while the client renders the actual value, creating text content mismatches.

🔧 Proposed fix
 import { useEffect, useMemo, useState } from 'react';
+import { useSearchParams } from 'next/navigation';
@@
-  const searchQuery = typeof window === 'undefined'
-    ? ''
-    : (new URLSearchParams(window.location.search).get('q') || '').trim();
+  const searchParams = useSearchParams();
+  const searchQuery = useMemo(
+    () => (searchParams.get('q') || '').trim(),
+    [searchParams],
+  );

Note: Wrap the component using useSearchParams in a <Suspense> boundary, as it suspends in statically rendered routes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app/ai-hub/context/page.tsx` around lines 14 - 16, The code
reads window.location.search during render to compute searchQuery, causing
hydration mismatches; replace that logic with Next.js App Router's
useSearchParams hook (import and call useSearchParams() and derive q via
searchParams.get('q')?.trim() in the component) and remove direct window access;
because useSearchParams can suspend in statically rendered routes, wrap the
component (or the part that uses useSearchParams) in a React.Suspense boundary
with a fallback to avoid runtime suspensions.
backend/tests/test_runtime_config_api.py-27-42 (1)

27-42: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore mutated global settings in finally to prevent cross-test leakage.

If client.get(...) or interim code throws before restoration, later tests inherit dirty auth/OIDC config and become order-dependent.

Suggested fix
 def test_runtime_config_exposes_non_secret_auth_capability_flags(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
+    try:
+        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")
+    finally:
+        settings.AUTH_MODE = previous_auth_mode
+        settings.TRUST_DEV_HEADERS = previous_trust
+        settings.OIDC_SHARED_SECRET = previous_secret
+        settings.OIDC_JWKS_URL = previous_jwks

Apply the same try/finally pattern to the second test that mutates OIDC fields.

Also applies to: 52-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_runtime_config_api.py` around lines 27 - 42, The test
mutates global settings (settings.AUTH_MODE, settings.TRUST_DEV_HEADERS,
settings.OIDC_SHARED_SECRET, settings.OIDC_JWKS_URL) and restores them only
after client.get("/api/runtime-config"), which can leak state if an exception
occurs; wrap the mutation + request in a try/finally block so restoration always
runs, and apply the same try/finally pattern to the other test that mutates OIDC
fields (the one around lines 52-73) to ensure global settings are always
restored.
frontend/src/app/page.tsx-109-114 (1)

109-114: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle refreshExecutionQueue() failures and unmounts in the effect.

The current fire-and-forget chain can emit unhandled rejections and may update state after unmount.

Suggested fix
   useEffect(() => {
+    let active = true;
     const syncExecutionQueue = () => setExecutionQueue(listExecutionQueue());
     syncExecutionQueue();
-    void refreshExecutionQueue().then(setExecutionQueue);
-    return subscribeExecutionQueue(syncExecutionQueue);
+    void refreshExecutionQueue()
+      .then((items) => {
+        if (active) setExecutionQueue(items);
+      })
+      .catch(() => {
+        if (active) syncExecutionQueue();
+      });
+    const unsubscribe = subscribeExecutionQueue(syncExecutionQueue);
+    return () => {
+      active = false;
+      unsubscribe();
+    };
   }, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app/page.tsx` around lines 109 - 114, The effect calls
refreshExecutionQueue() fire-and-forget which can produce unhandled rejections
and may call setExecutionQueue after the component unmounts; update the effect
in useEffect to (1) track mounted state (or use an AbortController) so you skip
calling setExecutionQueue if unmounted, (2) attach a .catch handler to
refreshExecutionQueue() to handle and log errors (or ignore them explicitly) to
avoid unhandled rejections, and (3) ensure the cleanup returned from useEffect
still calls subscribeExecutionQueue unsubscribe but also flips the mounted flag
(or aborts) so pending refreshExecutionQueue promises do not update state;
reference functions/identifiers: refreshExecutionQueue, syncExecutionQueue,
subscribeExecutionQueue, listExecutionQueue, setExecutionQueue, useEffect.
frontend/src/app/ai-hub/actions/page.tsx-38-43 (1)

38-43: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle queue refresh failures explicitly.

refreshExecutionQueue() is fire-and-forget with no .catch, so request failures can surface as unhandled rejections.

Suggested fix
  useEffect(() => {
+   let active = true;
    const syncQueue = () => setQueueItems(listExecutionQueue());
    syncQueue();
-   void refreshExecutionQueue().then(setQueueItems);
-   return subscribeExecutionQueue(syncQueue);
+   void refreshExecutionQueue()
+     .then((items) => {
+       if (active) setQueueItems(items);
+     })
+     .catch(() => {
+       // keep local queue snapshot; optional: report metric/log
+     });
+   const unsubscribe = subscribeExecutionQueue(syncQueue);
+   return () => {
+     active = false;
+     unsubscribe();
+   };
  }, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app/ai-hub/actions/page.tsx` around lines 38 - 43, The current
useEffect calls refreshExecutionQueue() fire-and-forget which can cause
unhandled promise rejections; modify the effect so the promise returned by
refreshExecutionQueue() is handled — e.g., call
refreshExecutionQueue().then(setQueueItems).catch(err => { /* log or set error
state */ }) or use an async wrapper to await it and handle errors; ensure the
change references the existing syncQueue, setQueueItems, listExecutionQueue,
refreshExecutionQueue, and subscribeExecutionQueue functions so failures are
explicitly caught and logged or surfaced to UI instead of being unhandled.
backend/api/search.py-49-53 (1)

49-53: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

The mailbox filter still returns unrelated legacy mail.

mailbox_scope_predicate() widens every scoped query to Email.mailbox_account_id IS NULL. For users with multiple mailbox accounts, any pre-migration/null rows from other mailboxes will appear in every mailbox-scoped search, and the same rows also inflate reply_count. If you need legacy thread context, only union null-mailbox rows that share a thread key with hits from the requested mailbox.

Also applies to: 68-70, 127-159

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/search.py` around lines 49 - 53, The current
mailbox_scope_predicate(mailbox_account_id) incorrectly includes all
Email.mailbox_account_id IS NULL rows; change it so NULL-mailbox rows are only
included when their thread_key matches threads that exist in the requested
mailbox. Replace the simple OR with a predicate that is either
Email.mailbox_account_id == mailbox_account_id OR
(Email.mailbox_account_id.is_(None) AND Email.thread_key.in_(subquery)), where
subquery selects thread_key from Email filtered by mailbox_account_id; update
callers (the other uses noted around the second occurrence and the block
handling thread/aggregate queries) to pass the same mailbox_account_id and let
the predicate use the thread-key subquery so only legacy null-mailbox rows that
share a thread with hits in the target mailbox are included.
backend/main.py-76-83 (1)

76-83: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Consolidate the duplicated CORS middleware.

This block is followed by a second CORSMiddleware registration at Lines 103-109 that only allows http://localhost:3000, so the newly added 127.0.0.1 and :8000 origins can still fail preflight depending on which layer handles the request. Keep a single middleware config here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/main.py` around lines 76 - 83, The project registers CORSMiddleware
twice which can cause inconsistent preflight behavior; consolidate into a single
app.add_middleware(CORSMiddleware, ...) registration that includes all required
origins (e.g., "http://localhost:3000", "http://127.0.0.1:3000",
"http://localhost:8000", "http://127.0.0.1:8000") and remove the duplicate
registration further down; update the existing CORSMiddleware call (the one near
app.add_middleware) rather than adding another, and ensure any other CORS
options (allow_methods, allow_headers, allow_credentials) from the removed block
are preserved in the single middleware config.
backend/api/calendar.py-15-23 (1)

15-23: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This helper makes /sync permanently unavailable.

get_calendar_credentials_for_user() always returns None, so Line 33 always raises 503 in real traffic. If the server-side credential store is not ready yet, gate the endpoint or keep a working temporary path instead of shipping a route that cannot succeed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/calendar.py` around lines 15 - 23,
get_calendar_credentials_for_user currently always returns None which makes
/sync always 503; replace the stub with a real retrieval from the server-side
store (e.g., call CalendarCredentialStore.get_credentials(auth_context.user_id)
or equivalent) and return the credential dict when present; if the credential
store is not yet ready, gate the endpoint by either (a) checking a fallback flag
ALLOW_TEMP_CALENDAR_FALLBACK and returning credentials from
TEMP_CALENDAR_CREDENTIALS when enabled, or (b) raising a clear
ServiceUnavailable/HTTPException(503) so the route fails fast but intentionally,
rather than silently forcing a permanent 503 for all traffic. Ensure you
reference get_calendar_credentials_for_user,
CalendarCredentialStore.get_credentials (or your store API),
ALLOW_TEMP_CALENDAR_FALLBACK/TEMP_CALENDAR_CREDENTIALS, and the
ServiceUnavailable/HTTPException path so the /sync handler can decide the
fallback behavior.
backend/services/mailbox_routing.py-10-19 (1)

10-19: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use RFC-compliant address parsing for mailbox routing.

The current implementation splits on [,;] and manually extracts angle-bracket contents, which fails to parse valid RFC address-list syntax. For example:

  • Team: alice@example.com, bob@example.com; → incorrectly extracts {'bob@example.com', 'team: alice@example.com'} instead of {'bob@example.com', 'alice@example.com'}
  • "Doe, Jane" <jane@example.com>, Bob <bob@example.com> → incorrectly extracts {'jane@example.com', 'bob@example.com', '"doe'} instead of {'jane@example.com', 'bob@example.com'}

When parsing fails, the function falls back to default or sole account selection, routing mail to the wrong mailbox.

Replace with email.utils.getaddresses, which correctly handles display names, quoted strings, and various separators:

Suggested fix
-import re
+from email.utils import getaddresses
@@
 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
+    return {
+        mailbox.strip().lower()
+        for _, mailbox in getaddresses([value])
+        if mailbox.strip()
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/mailbox_routing.py` around lines 10 - 19, The
_extract_mailboxes function currently splits addresses manually and misparses
RFC address-lists; replace its tokenization with Python's RFC-compliant parser
by using email.utils.getaddresses to parse the input string and collect the
lowercased, stripped mailbox parts (the second element of each tuple) into a
set; update references inside _extract_mailboxes to ignore empty results and
preserve current return semantics (return set() when input falsy), ensuring
quoted names and comma/semicolon separators are handled correctly.
frontend/src/components/EmailList.tsx-289-289 (1)

289-289: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pass the clicked email’s mailbox context, not only the current filter.

At Line 289, forwarding selectedMailboxAccountId can lose the actual mailbox for that email when filter is “all” (or mismatch context), causing downstream actions to use the wrong sender account.

Proposed fix
-                      onSelectEmail(email.id, email, selectedMailboxAccountId);
+                      onSelectEmail(
+                        email.id,
+                        email,
+                        email.mailbox_account_id ?? selectedMailboxAccountId ?? null,
+                      );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/EmailList.tsx` at line 289, The call to onSelectEmail
currently forwards only selectedMailboxAccountId which can misattribute the
clicked email when the view is an "all" filter; change the argument to use the
clicked email's own mailbox context (e.g. email.mailboxAccountId) instead of
selectedMailboxAccountId, or fallback to selectedMailboxAccountId if the email
field is missing (onSelectEmail(email.id, email, email.mailboxAccountId ||
selectedMailboxAccountId)); update any downstream expectations in onSelectEmail
handlers to accept and use that mailbox value.
frontend/src/lib/execution-queue.ts-119-123 (1)

119-123: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Disambiguate lookup to avoid completing the wrong queue item.

At Line 119, matching sourceEmailId === emailId || id === emailId in one pass can pick the wrong item when numeric IDs overlap across tables.

Proposed fix
-  let target = queue.find((item) => item.sourceEmailId === emailId || item.id === emailId) || null;
+  let target =
+    queue.find((item) => item.sourceEmailId === emailId) ??
+    queue.find((item) => item.id === emailId) ??
+    null;
   if (!target) {
     queue = await refreshExecutionQueue();
-    target = queue.find((item) => item.sourceEmailId === emailId || item.id === emailId) || null;
+    target =
+      queue.find((item) => item.sourceEmailId === emailId) ??
+      queue.find((item) => item.id === emailId) ??
+      null;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/execution-queue.ts` around lines 119 - 123, The current
lookup that combines sourceEmailId === emailId || id === emailId can return the
wrong item when numeric ids overlap; change the lookup to first try finding by
sourceEmailId only (queue.find(item => item.sourceEmailId === emailId)), and
only if that yields null then try by id (queue.find(item => item.id ===
emailId)); apply the same two-step logic after calling refreshExecutionQueue()
so both the initial and refreshed searches prefer sourceEmailId over id; refer
to the variables/funcs queue, target, sourceEmailId, id, emailId and
refreshExecutionQueue() when making the change.
frontend/src/lib/runtime-config.ts-18-24 (1)

18-24: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear cached promise on fetch failure to allow retry.

At Line 19, a failed /api/runtime-config request leaves runtimeConfigPromise in a permanently rejected state, so future calls cannot recover unless resetRuntimeConfigCache() is called externally.

Proposed fix
 export function getRuntimeConfig() {
   if (!runtimeConfigPromise) {
-    runtimeConfigPromise = apiClient.get<RuntimeConfig>('/api/runtime-config').then((config) => {
-      apiClient.setDevHeaderAuthEnabled(config.features.dev_header_auth_enabled);
-      return config;
-    });
+    runtimeConfigPromise = apiClient
+      .get<RuntimeConfig>('/api/runtime-config')
+      .then((config) => {
+        apiClient.setDevHeaderAuthEnabled(config.features.dev_header_auth_enabled);
+        return config;
+      })
+      .catch((error) => {
+        runtimeConfigPromise = null;
+        throw error;
+      });
   }
   return runtimeConfigPromise;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/runtime-config.ts` around lines 18 - 24, The cached promise
runtimeConfigPromise can stay permanently rejected after
apiClient.get('/api/runtime-config') fails; update the getRuntimeConfig flow so
that when apiClient.get (the promise returned inside runtimeConfigPromise)
rejects you clear runtimeConfigPromise (set it back to undefined/null) before
rethrowing the error so subsequent calls can retry; implement this by attaching
a .catch handler to the promise returned by apiClient.get in the block that
assigns runtimeConfigPromise and in that handler reset runtimeConfigPromise and
rethrow the original error (no external resetRuntimeConfigCache call required).
frontend/src/components/EmailList.tsx-294-294 (1)

294-294: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not execute queue/complete actions on pointer cancel.

At Line 294, onPointerCancel reuses handlePointerEnd, so canceled gestures can still enqueue/complete items. Cancel should only reset swipe state.

Proposed fix
+  const handlePointerCancel = (emailId: number) => (event: React.PointerEvent<HTMLButtonElement>) => {
+    const currentTarget = event.currentTarget;
+    setSwipeOffsets((prev) => ({ ...prev, [emailId]: 0 }));
+    swipeOffsetsRef.current[emailId] = 0;
+    pointerStateRef.current = null;
+    if (
+      capturedPointerIdsRef.current[emailId] === event.pointerId &&
+      (typeof currentTarget.hasPointerCapture !== 'function' || currentTarget.hasPointerCapture(event.pointerId))
+    ) {
+      currentTarget.releasePointerCapture(event.pointerId);
+    }
+    delete capturedPointerIdsRef.current[emailId];
+  };
...
-                    onPointerCancel={handlePointerEnd(email)}
+                    onPointerCancel={handlePointerCancel(email.id)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/EmailList.tsx` at line 294, The pointer-cancel
currently calls handlePointerEnd(email) which triggers enqueue/complete actions;
instead create a new handler (e.g., handlePointerCancel) that only resets the
swipe/drag state (clear dragging flags, startX, translateX/offset, any active
pointerId) without performing enqueue/complete logic, and replace
onPointerCancel={handlePointerEnd(email)} with
onPointerCancel={handlePointerCancel(email)} (or bind appropriately); keep
handlePointerEnd(email) for actual pointer up/end logic that commits actions.
frontend/src/components/EmailList.test.tsx-39-42 (1)

39-42: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset apiClient singleton state in teardown to avoid cross-test leakage.

These tests enable dev-header auth globally but never restore it. That can create suite-order flakiness in other tests.

Suggested fix
   afterEach(() => {
     if (root) {
       act(() => root?.unmount());
     }
+    apiClient.setBaseUrl("");
+    apiClient.setDevHeaderAuthEnabled(false);
     root = null;
     container?.remove();
     container = null;
     vi.unstubAllGlobals();
   });

Also applies to: 44-52

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/EmailList.test.tsx` around lines 39 - 42, Tests
enable dev-header auth on the apiClient singleton in beforeEach but never
restore it, risking cross-test leakage; add an afterEach teardown that resets
the singleton state by calling apiClient.setDevHeaderAuthEnabled(false) and
apiClient.setBaseUrl('') (or restore any saved original values) so the apiClient
global is returned to a clean state after each test.
backend/api/mailbox_accounts.py-244-246 (1)

244-246: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate against the effective account state, not only patch fields.

A partial PATCH can break the server/port pairing invariant (e.g., smtp_server=null with existing smtp_port retained). Validate using merged state (current account + update) before persisting.

Suggested fix pattern
-    update_data = _validate_mailbox_payload(
-        _sanitize_mailbox_payload(payload.model_dump(exclude_unset=True))
-    )
+    patch_data = _sanitize_mailbox_payload(payload.model_dump(exclude_unset=True))
+    merged = {
+        "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": account.smtp_password,
+        "imap_server": account.imap_server,
+        "imap_port": account.imap_port,
+        "imap_username": account.imap_username,
+        "imap_password": account.imap_password,
+        "pop3_server": account.pop3_server,
+        "pop3_port": account.pop3_port,
+        "pop3_username": account.pop3_username,
+        "pop3_password": account.pop3_password,
+    }
+    merged.update(patch_data)
+    merged = _validate_mailbox_payload(merged)
+    update_data = {key: merged[key] for key in patch_data.keys()}

Also applies to: 249-253

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/mailbox_accounts.py` around lines 244 - 246, The current PATCH
logic validates only the partial payload, which can break invariants (e.g., null
smtp_server with existing smtp_port); instead, load the existing account state
(e.g., mailbox_account or found account dict), merge it with
payload.model_dump(exclude_unset=True) to produce the effective/merged account
state, then pass that merged dict into _sanitize_mailbox_payload and
_validate_mailbox_payload to produce update_data before persisting; apply the
same merged-state validation pattern to the other similar block referenced
(lines ~249-253) so all patch handling validates the effective account state
rather than only the patch fields.
backend/api/emails.py-212-220 (1)

212-220: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce active-state check for explicit mailbox sends.

At Line 214, the explicit mailbox lookup only checks owner/id. An inactive mailbox account can still be used to send mail when mailbox_account_id is provided, which bypasses deactivation intent.

🔧 Proposed fix
         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,
+                    MailboxAccount.is_active.is_(True),
                 )
             )
             if not mailbox_account:
-                raise HTTPException(status_code=404, detail="Mailbox account not found")
+                raise HTTPException(
+                    status_code=404,
+                    detail="Mailbox account not found",
+                )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/emails.py` around lines 212 - 220, Lookup for an explicit mailbox
(when request.mailbox_account_id is set) currently only validates id/owner and
can allow inactive accounts; update the validation in the mailbox lookup for
MailboxAccount (or immediately after fetching mailbox_account) to enforce the
active state for target_user_id by either adding MailboxAccount.active == True
to the select().where(...) clause or checking mailbox_account.active and raising
HTTPException(status_code=404, detail="Mailbox account not found") when false,
so inactive mailbox accounts cannot be used for sends.
🟡 Minor comments (11)
backend/tests/test_email_client_smtp.py-44-58 (1)

44-58: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Patch/assert the actual send helper, not aiosmtplib.send

These checks are now disconnected from runtime behavior because send_email no longer calls aiosmtplib.send. Assert against _send_message_via_validated_smtp for meaningful coverage.

Suggested adjustment
-    with patch(
-        "services.email_client.aiosmtplib.send", new_callable=AsyncMock
-    ) as smtp_send:
+    with patch.object(
+        email_client, "_send_message_via_validated_smtp", new_callable=AsyncMock
+    ) as send_mock:
         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()
+    send_mock.assert_not_awaited()

Also applies to: 89-107

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_email_client_smtp.py` around lines 44 - 58, The test
patches and asserts against aiosmtplib.send, but send_email no longer calls that
helper; update the test to patch and assert the internal helper
_send_message_via_validated_smtp instead so the assertion reflects runtime
behavior. Specifically, replace the patch target
"services.email_client.aiosmtplib.send" with the function/path for
_send_message_via_validated_smtp (used by send_email) and assert that this mock
was not awaited (or awaited) as appropriate; do the same replacement for the
second occurrence covering lines 89–107 so both tests validate the actual send
helper invoked by send_email.
docs/plans/2026-05-14-naruon-full-scope-remediation-design.md-14-14 (1)

14-14: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove machine-specific absolute path from the plan

Line 14 hardcodes a local path (/home/...), which is not portable for other contributors. Use a repo-relative reference (or a documented artifact location) instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/plans/2026-05-14-naruon-full-scope-remediation-design.md` at line 14,
Replace the machine-specific absolute path
`/home/seongho/ai_email_client/frontend/branding/uiux` in the plan with a
portable repo-relative or documented-artifact reference (for example
`frontend/branding/uiux` or a pointer to a documented artifact location), and
update the sentence that sets it as the "read-only truth source" accordingly in
docs/plans/2026-05-14-naruon-full-scope-remediation-design.md so other
contributors can locate the assets without relying on a local filesystem path.
frontend/src/app/ai-hub/context/page.tsx-18-32 (1)

18-32: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset loading/error state on each query-driven fetch.

When searchQuery changes, loading is not re-enabled and prior error is not cleared, so stale error/loading UI can persist across successful subsequent loads.

🩹 Proposed fix
   useEffect(() => {
     let active = true;
     const loadEmails = async () => {
+      setLoading(true);
+      setError(null);
       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 || []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app/ai-hub/context/page.tsx` around lines 18 - 32, The effect's
fetch (loadEmails inside useEffect) doesn't reset loading/error when a new
searchQuery triggers it, so call setLoading(true) and setError(undefined) (or
'') at the start of loadEmails (or immediately in the useEffect before invoking
loadEmails) to re-enable the loading state and clear any prior error; keep the
existing active guard and existing setEmails/setLoading/setError usage so the
rest of the logic (try/catch/finally) remains unchanged.
frontend/src/lib/execution-queue.test.ts-134-137 (1)

134-137: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid redefining window.location directly in this test.

Overriding window.location with Object.defineProperty is brittle in jsdom and can leak across cases. Prefer history.replaceState(...) and restore URL in teardown.

Suggested fix
-    Object.defineProperty(window, 'location', {
-      configurable: true,
-      value: new URL('http://localhost/settings'),
-    });
+    const originalPath = window.location.pathname;
+    window.history.replaceState({}, '', '/settings');
...
+    window.history.replaceState({}, '', originalPath);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/execution-queue.test.ts` around lines 134 - 137, The test
currently redefines window.location with Object.defineProperty (setting
window.location to new URL('http://localhost/settings')), which is brittle;
replace that line with history.replaceState(null, '', '/settings') to change the
URL within jsdom safely and add a teardown step that restores the original URL
(save location.href before change and call history.replaceState(null, '',
originalHref) in afterEach/teardown). Update the test where window.location is
set and ensure any references to window.location continue to work with the
replaced history URL.
frontend/src/components/EmailDetail.test.tsx-104-107 (1)

104-107: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset apiClient test mutations in teardown.

beforeEach enables dev-header auth on a shared singleton, but it isn’t restored in afterEach, which can leak state into other suites.

Suggested fix
  afterEach(() => {
    if (root) {
      act(() => root?.unmount());
    }
    root = null;
    container?.remove();
    container = null;
+   apiClient.setDevHeaderAuthEnabled(false);
+   apiClient.setBaseUrl('');
    vi.unstubAllGlobals();
  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/EmailDetail.test.tsx` around lines 104 - 107, The
tests enable dev-header auth on the shared singleton in the beforeEach
(apiClient.setDevHeaderAuthEnabled(true)) but never restore it; add an afterEach
that resets any mutated apiClient state (call
apiClient.setDevHeaderAuthEnabled(false) and restore base URL to '' via
apiClient.setBaseUrl('') or other defaults) so the singleton is returned to its
original state for other suites; target the teardown near the existing
beforeEach in EmailDetail.test.tsx.
backend/tests/test_imap_worker_sync.py-53-55 (1)

53-55: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Escape regex metacharacters in pytest.raises(match=...).

The match parameter uses regex; unescaped dots in alpha@example.com allow the assertion to match unintended strings like alpha@exampleXcom (where X is any character).

Suggested fix
-    with pytest.raises(
-        Exception, match="IMAP Sync failed for user testuser mailbox alpha@example.com"
-    ):
+    with pytest.raises(
+        Exception, match=r"IMAP Sync failed for user testuser mailbox alpha@example\.com"
+    ):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_imap_worker_sync.py` around lines 53 - 55, The test's
pytest.raises(match=...) uses an unescaped literal email which is treated as a
regex; update the assertion to escape regex metacharacters (e.g. use re.escape
on "IMAP Sync failed for user testuser mailbox alpha@example.com") so dots and
other special chars are treated literally, and add the necessary import for the
re module; target the pytest.raises(...) call and replace the raw literal match
with an escaped string.
backend/scripts/import_fixtures.py-51-58 (1)

51-58: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Look up the API key for the configured legacy owner.

After introducing owner_user_id, this query still hardcodes "default". Imports for any other legacy owner will ignore that owner's TenantConfig and silently fall back to the env var or an empty key.

Suggested fix
                     tenant_config = await session.scalar(
-                        select(TenantConfig).where(TenantConfig.user_id == "default")
+                        select(TenantConfig).where(
+                            TenantConfig.user_id == owner_user_id
+                        )
                     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/scripts/import_fixtures.py` around lines 51 - 58, The TenantConfig
lookup is hardcoded to "default" so imports for other legacy owners ignore their
configured key; change the select that populates tenant_config to filter on
TenantConfig.user_id == owner_user_id (use the existing owner_user_id variable
instead of the literal "default") so tenant_config = await
session.scalar(select(TenantConfig).where(TenantConfig.user_id ==
owner_user_id)); keep the existing fallback logic to os.getenv("OPENAI_API_KEY")
or "" so imports still fall back when a per-owner key is absent.
frontend/src/app/compose/ComposePageClient.tsx-41-42 (1)

41-42: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid defaulting to an inactive reply account.

At Line 41, fallback currently uses data.items[0], which can preselect an inactive account and lead to immediate send failures. Prefer the first active account as fallback.

Proposed fix
-        const defaultAccount = data.items.find((item) => item.is_default_reply && item.is_active) || data.items[0] || null;
+        const defaultAccount =
+          data.items.find((item) => item.is_default_reply && item.is_active) ??
+          data.items.find((item) => item.is_active) ??
+          null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app/compose/ComposePageClient.tsx` around lines 41 - 42, The
fallback currently uses data.items[0] which may be inactive; change the
selection logic so defaultAccount is set to the item that is both
is_default_reply and is_active, or if none, the first item where is_active is
true, or null; update the code that computes defaultAccount (the expression
using data.items.find(...) and the subsequent setSelectedMailboxAccountId call)
to use data.items.find(item => item.is_active) as the fallback instead of
data.items[0].
backend/api/auth.py-125-130 (1)

125-130: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle bearer scheme case-insensitively.

The current check rejects valid authorization: bearer <token> headers. This can cause avoidable 401s with some clients/proxies.

Suggested fix
-    if not authorization.startswith("Bearer "):
-        raise HTTPException(status_code=401, detail="Invalid bearer token")
-
-    token = authorization.split(" ", 1)[1].strip()
+    scheme, _, raw_token = authorization.partition(" ")
+    if scheme.lower() != "bearer":
+        raise HTTPException(status_code=401, detail="Invalid bearer token")
+
+    token = raw_token.strip()
     if not token:
         raise HTTPException(status_code=401, detail="Invalid bearer token")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/auth.py` around lines 125 - 130, The bearer-scheme check on the
authorization header is case-sensitive and rejects headers like "bearer
<token>"; update the logic that inspects the variable authorization to compare
the scheme case-insensitively (e.g., lowercasing the scheme before comparing or
splitting into scheme and token and checking scheme.lower() == "bearer"), then
extract the token (token variable) safely (split once or use partition) and keep
the existing 401 on missing token. Ensure you update the block containing
authorization and token so valid mixed-case "Bearer"/"bearer" headers are
accepted.
frontend/src/lib/api-client.ts-96-109 (1)

96-109: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep localhost fallback identity in sync with the dev-header gate.

These getters fall back to local-dev user/role/org values even when ensureDevHeaderAuthGate() has not enabled dev-header auth. On localhost with dev headers disabled, claim-gated UI can render as if a user or admin session exists while every request still goes out unauthenticated.

Suggested fix
+  private canUseLocalDevIdentity() {
+    return this.devHeaderAuthEnabled && this.isLocalDevOverrideAllowed();
+  }
+
   private getLocalDevUserId() {
-    if (!this.isLocalDevOverrideAllowed() || typeof window === 'undefined') return null;
+    if (!this.canUseLocalDevIdentity() || typeof window === 'undefined') return null;
     const stored = localStorage.getItem('naruon_dev_user')?.trim();
     return stored || 'testuser';
   }
@@
   private getLocalDevOrganizationId() {
-    if (!this.isLocalDevOverrideAllowed()) return null;
+    if (!this.canUseLocalDevIdentity()) return null;
     return 'org-local-dev';
   }

Also applies to: 147-193

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/api-client.ts` around lines 96 - 109, The local-dev getters
currently return fallbacks even when dev-header auth is not enabled, causing UI
to appear authenticated; update getLocalDevUserId, getLocalDevRole, and
getLocalDevOrganizationId to only return values when the dev-header gate allows
overrides (use isLocalDevOverrideAllowed() and the same window check), and make
getLocalDevRole return null if getLocalDevUserId() is null (instead of
defaulting to 'member'); apply the same guard pattern to any other local-dev
getters referenced elsewhere (the ones around
getLocalDevUserId/getLocalDevRole/getLocalDevOrganizationId) so the UI and
request auth state stay in sync.
frontend/src/lib/api-client.ts-11-23 (1)

11-23: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject malformed bearer tokens before treating them as a valid session.

parts.length < 2 still accepts two-segment strings, so a malformed pasted token can survive client-side validation and drive the logged-in UI until the backend rejects it.

Suggested fix
 function decodeBearerClaims(token: string): Record<string, unknown> | null {
   const parts = token.split('.');
-  if (parts.length < 2) return null;
+  if (parts.length !== 3) return null;
   try {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/api-client.ts` around lines 11 - 23, The function
decodeBearerClaims currently allows tokens with two segments (parts.length < 2)
which lets malformed tokens pass; update the validation to require exactly three
JWT segments by replacing the check with parts.length !== 3 and return null for
any non-3-segment input, keeping the rest of decodeBearerClaims (base64url
decode, JSON.parse, exp check) unchanged; reference: decodeBearerClaims.

Comment thread backend/requirements.txt
google-auth-oauthlib==1.2.0
email-validator==2.1.0
cryptography==47.0.0
PyJWT==2.10.1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify PyJWT 2.10.1 exists and check for security advisories

# Check if version 2.10.1 exists on PyPI
echo "=== Checking PyJWT version 2.10.1 on PyPI ==="
curl -s https://pypi.org/pypi/PyJWT/json | jq -r '.releases | keys[] | select(. == "2.10.1")'

# Check latest version
echo -e "\n=== Latest PyJWT version ==="
curl -s https://pypi.org/pypi/PyJWT/json | jq -r '.info.version'

# Check for security advisories
echo -e "\n=== Security advisories for PyJWT ==="
gh api graphql -f query='
{
  securityVulnerabilities(first: 10, ecosystem: PIP, package: "PyJWT") {
    nodes {
      advisory {
        summary
        severity
        publishedAt
      }
      vulnerableVersionRange
      firstPatchedVersion {
        identifier
      }
    }
  }
}'

Repository: Seongho-Bae/naruon

Length of output: 1090


Upgrade PyJWT to version 2.12.0 or later to fix HIGH severity security vulnerability.

PyJWT 2.10.1 is valid but contains a HIGH severity vulnerability ("PyJWT accepts unknown crit header extensions") affecting all versions up to 2.11.0. This vulnerability is critical for JWT/OIDC authentication. The fix is available in version 2.12.0+. Upgrade to PyJWT>=2.12.0 (latest: 2.12.1).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/requirements.txt` at line 20, requirements.txt pins PyJWT to 2.10.1
which is vulnerable; update the PyJWT entry to a secure release (e.g., change
"PyJWT==2.10.1" to "PyJWT>=2.12.0" or "PyJWT==2.12.1") and then regenerate any
dependency lock files (pip-tools/poetry/poetry.lock) and run your test suite to
ensure no breaking changes; search for the literal "PyJWT==2.10.1" in the repo
to locate the exact requirement line to edit.

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for e526de8e9b87c8a7cce93e2e188a2f8064122aba:

  • 9 unresolved current review thread(s) remain.\n- 3 required check(s) are not successful on e526de8.\n- Missing current-head CodeRabbit/coderabbitai evidence for e526de8.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for e526de8e9b87c8a7cce93e2e188a2f8064122aba:

  • 8 unresolved current review thread(s) remain.\n- 2 required check(s) are not successful on e526de8.\n- Missing current-head CodeRabbit/coderabbitai evidence for e526de8.\n

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for e526de8e9b87c8a7cce93e2e188a2f8064122aba:

  • 8 unresolved current review thread(s) remain.\n- 2 required check(s) are not successful on e526de8.\n- Missing current-head CodeRabbit/coderabbitai evidence for e526de8.\n

Comment thread backend/api/emails.py
Comment on lines +156 to +162
if mailbox_account_id is not None:
statement = statement.where(
or_(
Email.mailbox_account_id == mailbox_account_id,
Email.mailbox_account_id.is_(None),
)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 스레드 조회 시 mailbox_account_id를 지정해도 mailbox_account_id IS NULL인 레거시 이메일이 함께 반환됩니다. get_emails는 동일 파라미터에 등호 조건만 사용하므로, 스레드 뷰에서만 다른 메일함의 레거시 메일이 섞여 나와 두 엔드포인트 간 필터링 결과가 일치하지 않습니다.

Suggested change
if mailbox_account_id is not None:
statement = statement.where(
or_(
Email.mailbox_account_id == mailbox_account_id,
Email.mailbox_account_id.is_(None),
)
)
if mailbox_account_id is not None:
statement = statement.where(
Email.mailbox_account_id == mailbox_account_id
)

Comment thread backend/api/emails.py
@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for e526de8e9b87c8a7cce93e2e188a2f8064122aba:

  • 10 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on e526de8.\n- Missing current-head CodeRabbit/coderabbitai evidence for e526de8.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 30af8fce8da5e65aceac16db7d3d238fe109c514:

  • 10 unresolved current review thread(s) remain.\n- 3 required check(s) are not successful on 30af8fc.\n- Missing current-head CodeRabbit/coderabbitai evidence for 30af8fc.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 30af8fce8da5e65aceac16db7d3d238fe109c514:

  • 10 unresolved current review thread(s) remain.\n- 2 required check(s) are not successful on 30af8fc.\n- Missing current-head CodeRabbit/coderabbitai evidence for 30af8fc.\n

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 30af8fce8da5e65aceac16db7d3d238fe109c514:

  • 10 unresolved current review thread(s) remain.\n- 2 required check(s) are not successful on 30af8fc.\n- Missing current-head CodeRabbit/coderabbitai evidence for 30af8fc.\n

Comment thread backend/tests/test_prompts_api.py Fixed
Comment thread backend/tests/test_prompts_api.py Fixed
Comment thread backend/tests/test_prompts_api.py Fixed
Comment thread backend/tests/test_prompts_api.py Fixed
Comment thread backend/tests/test_prompts_api.py Fixed
Comment thread backend/tests/test_prompts_api.py Fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/DashboardLayout.test.tsx (1)

28-37: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset sessionStorage in teardown to keep tests isolated.

Line 297 sets naruon.sidebarScrollTop, but teardown never clears sessionStorage. That can leak state into later tests and make failures order-dependent.

Proposed fix
   afterEach(() => {
     if (root) {
       act(() => root?.unmount());
     }
     root = null;
     container?.remove();
     container = null;
+    window.sessionStorage.clear();
     vi.clearAllMocks();
     apiClientMock.canManageWorkspaceSettings.mockReturnValue(false);
   });

Also applies to: 296-314

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/DashboardLayout.test.tsx` around lines 28 - 37, The
afterEach teardown in DashboardLayout.test.tsx currently resets root/container
and mocks but does not clear sessionStorage, so add a sessionStorage cleanup
(e.g., removeItem for "naruon.sidebarScrollTop" or sessionStorage.clear()) to
the afterEach block that contains root?.unmount(), container?.remove(),
vi.clearAllMocks(), and
apiClientMock.canManageWorkspaceSettings.mockReturnValue(false); ensure you
remove the specific key set by the tests (naruon.sidebarScrollTop) to avoid
leaking state between tests.
🧹 Nitpick comments (1)
backend/tests/test_prompts_api.py (1)

251-258: ⚡ Quick win

Strengthen SQL scoping assertions to avoid false positives.

Line 257 currently checks for "created_by" anywhere in SQL text, which can still pass even if the actual scoping predicate regresses. Prefer asserting against the WHERE clause and expected scoped params.

Suggested tightening
     statement = build_prompt_list_statement(auth_context)
-    sql = str(statement).lower()
-    params = statement.compile().params
+    compiled_sql = str(statement.compile()).lower()
+    params = statement.compile().params
+    where_clause = compiled_sql.split(" where ", 1)[1]

-    assert "prompt_templates.organization_id" in sql
-    assert "prompt_templates.is_shared" in sql
-    assert "created_by" in sql
+    assert "prompt_templates.organization_id" in where_clause
+    assert "prompt_templates.is_shared" in where_clause
+    assert "prompt_templates.created_by" in where_clause
     assert "org-current" in params.values()
+    assert "testuser" in params.values()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_prompts_api.py` around lines 251 - 258, The test's SQL
assertions are too loose: instead of asserting "created_by" anywhere in sql,
extract the WHERE clause from the generated SQL (use the string from statement
or str(statement).lower() and split/find " where ") and assert the specific
scoping predicate is present (e.g. "prompt_templates.created_by =" or
"prompt_templates.created_by = :created_by") and that the corresponding param
key exists in statement.compile().params; keep the existing checks for
"prompt_templates.organization_id" and "prompt_templates.is_shared" but replace
the loose "created_by" assertion with a targeted WHERE-clause assertion
referencing build_prompt_list_statement, statement, sql, and params.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ARCHITECTURE.md`:
- Around line 142-144: Replace the unhyphenated phrase "org scoped APIs" with
the hyphenated compound adjective "org-scoped APIs" in the ARCHITECTURE.md text
(search for the exact snippet "org scoped APIs" near the sentence mentioning
`LEGACY_LLM_PROVIDER_ORGANIZATION_ID` and update it to "org-scoped APIs") to
ensure consistent technical wording.

In `@backend/api/prompts.py`:
- Around line 144-146: The error message is misleading when
auth_context.organization_id is missing; update the HTTPException raised in the
branch that checks auth_context.organization_id to use an org-scoped message
(e.g. "organization context not configured" or "organization ID not provided")
instead of "LLM API key not configured" so clients can distinguish missing org
scope; locate the check referencing auth_context.organization_id and change the
HTTPException detail accordingly while keeping status_code=400.
- Around line 61-68: The except block in backend/api/prompts.py that currently
does "except Exception as e" and logs via
logging.getLogger(__name__).error(f"Prompt execution failed: {e}") should
preserve the original traceback and chain the exception; update the logging call
to include exc_info=True (or use logger.exception) so the traceback is recorded,
and re-raise the HTTPException using "raise HTTPException(... ) from e" to
maintain exception chaining for callers; target the except block surrounding the
prompt execution logic and adjust the logger/error and raise statements
accordingly.

In `@docs/plans/2026-05-15-dashboard-mobile-rbac-product-remediation.md`:
- Line 21: The document contains a machine-local absolute path
'/home/seongho/ai_email_client/frontend/branding/uiux/' which is not portable;
replace that string with a repo-relative path (for example
'frontend/branding/uiux/' or './frontend/branding/uiux/') or use a documented
variable/placeholder (e.g., '<repo-root>/frontend/branding/uiux/') so
contributors can resolve it in any environment and update the reference in the
same line where the absolute path appears.

In `@frontend/src/app/prompt-studio/page.tsx`:
- Around line 36-45: The call to apiClient.ensureWorkspaceSettingsAccessReady()
inside loadAccessState can reject and is currently uncaught, which may leave the
UI stuck; wrap the await in a try/catch (or try/catch/finally) around
ensureWorkspaceSettingsAccessReady() in loadAccessState, and in the catch set a
safe accessState (e.g., ready: apiClient.isWorkspaceSettingsAccessReady() ||
true/false and canManage: apiClient.canManageWorkspaceSettings() || false) or
otherwise ensure setAccessState is always called so the loading UI clears;
reference the loadAccessState function and the methods
apiClient.ensureWorkspaceSettingsAccessReady,
apiClient.isWorkspaceSettingsAccessReady, apiClient.canManageWorkspaceSettings,
and the setAccessState call when making the change.

---

Outside diff comments:
In `@frontend/src/components/DashboardLayout.test.tsx`:
- Around line 28-37: The afterEach teardown in DashboardLayout.test.tsx
currently resets root/container and mocks but does not clear sessionStorage, so
add a sessionStorage cleanup (e.g., removeItem for "naruon.sidebarScrollTop" or
sessionStorage.clear()) to the afterEach block that contains root?.unmount(),
container?.remove(), vi.clearAllMocks(), and
apiClientMock.canManageWorkspaceSettings.mockReturnValue(false); ensure you
remove the specific key set by the tests (naruon.sidebarScrollTop) to avoid
leaking state between tests.

---

Nitpick comments:
In `@backend/tests/test_prompts_api.py`:
- Around line 251-258: The test's SQL assertions are too loose: instead of
asserting "created_by" anywhere in sql, extract the WHERE clause from the
generated SQL (use the string from statement or str(statement).lower() and
split/find " where ") and assert the specific scoping predicate is present (e.g.
"prompt_templates.created_by =" or "prompt_templates.created_by = :created_by")
and that the corresponding param key exists in statement.compile().params; keep
the existing checks for "prompt_templates.organization_id" and
"prompt_templates.is_shared" but replace the loose "created_by" assertion with a
targeted WHERE-clause assertion referencing build_prompt_list_statement,
statement, sql, and params.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 47c04d9f-d00d-430d-8805-1a7d57d77822

📥 Commits

Reviewing files that changed from the base of the PR and between e526de8 and 30af8fc.

📒 Files selected for processing (18)
  • ARCHITECTURE.md
  • backend/api/prompts.py
  • backend/tests/test_prompts_api.py
  • docs/operations/auth-key-management.md
  • docs/plans/2026-05-14-synthetic-mailbox-platform-program.md
  • docs/plans/2026-05-15-dashboard-mobile-rbac-product-remediation.md
  • frontend/src/app/layout.test.tsx
  • frontend/src/app/layout.tsx
  • frontend/src/app/prompt-studio/page.test.tsx
  • frontend/src/app/prompt-studio/page.tsx
  • frontend/src/components/DashboardLayout.test.tsx
  • frontend/src/components/DashboardLayout.tsx
  • frontend/src/components/DevAuthSwitcher.test.tsx
  • frontend/src/components/DevAuthSwitcher.tsx
  • frontend/src/components/EmailList.test.tsx
  • frontend/src/lib/api-client.test.ts
  • frontend/src/lib/api-client.ts
  • frontend/tests/e2e/dashboard-branding.spec.ts
✅ Files skipped from review due to trivial changes (1)
  • frontend/src/app/layout.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • frontend/src/components/DevAuthSwitcher.tsx
  • frontend/src/components/DashboardLayout.tsx
  • frontend/src/lib/api-client.ts
  • frontend/tests/e2e/dashboard-branding.spec.ts
  • frontend/src/components/EmailList.test.tsx

Comment thread ARCHITECTURE.md
Comment thread backend/api/prompts.py
Comment on lines 61 to +68
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.",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "prompts.py" | head -20

Repository: Seongho-Bae/naruon

Length of output: 87


🏁 Script executed:

cat -n ./backend/api/prompts.py | sed -n '50,75p'

Repository: Seongho-Bae/naruon

Length of output: 1175


🏁 Script executed:

rg "except Exception" ./backend/api/prompts.py -A 5

Repository: Seongho-Bae/naruon

Length of output: 246


🏁 Script executed:

rg "logging\.exception|from e:|from err:" ./backend --type py | head -20

Repository: Seongho-Bae/naruon

Length of output: 44


🏁 Script executed:

rg "except Exception" ./backend --type py -A 3 | head -50

Repository: Seongho-Bae/naruon

Length of output: 3156


🏁 Script executed:

cat -n ./backend/services/llm_service.py | sed -n '40,70p'

Repository: Seongho-Bae/naruon

Length of output: 1253


Use exception chaining and proper exception logging to preserve debugging context in provider failure path.

Lines 61-68 catch exceptions and rethrow without explicit chaining or proper exception logging. The current code uses .error() with string formatting, which loses the original traceback. This makes production debugging harder.

The codebase already establishes a pattern of using from e for exception chaining (see llm_service.py and calendar_service.py) and exc_info=True for traceback logging (see workers). Align this handler with those patterns:

Proposed fix
-    except Exception as e:
+    except Exception as err:
         import logging
 
-        logging.getLogger(__name__).error(f"Prompt execution failed: {e}")
+        logging.getLogger(__name__).exception("Prompt execution failed")
         raise HTTPException(
             status_code=502,
             detail="Failed to execute prompt with AI provider. Check provider status.",
-        )
+        ) from err
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.",
)
except Exception as err:
import logging
logging.getLogger(__name__).exception("Prompt execution failed")
raise HTTPException(
status_code=502,
detail="Failed to execute prompt with AI provider. Check provider status.",
) from err
🧰 Tools
🪛 Ruff (0.15.12)

[warning] 61-61: Do not catch blind exception: Exception

(BLE001)


[warning] 65-68: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/prompts.py` around lines 61 - 68, The except block in
backend/api/prompts.py that currently does "except Exception as e" and logs via
logging.getLogger(__name__).error(f"Prompt execution failed: {e}") should
preserve the original traceback and chain the exception; update the logging call
to include exc_info=True (or use logger.exception) so the traceback is recorded,
and re-raise the HTTPException using "raise HTTPException(... ) from e" to
maintain exception chaining for callers; target the except block surrounding the
prompt execution logic and adjust the logger/error and raise statements
accordingly.

Comment thread backend/api/prompts.py
Comment on lines +144 to +146
if not auth_context.organization_id:
raise HTTPException(status_code=400, detail="LLM API key not configured")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use an org-scope error message for missing organization context.

Line 145 returns "LLM API key not configured" when the actual failure is missing organization scope. This makes client handling ambiguous.

Proposed fix
-    if not auth_context.organization_id:
-        raise HTTPException(status_code=400, detail="LLM API key not configured")
+    if not auth_context.organization_id:
+        raise HTTPException(status_code=400, detail="Organization scope is required")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not auth_context.organization_id:
raise HTTPException(status_code=400, detail="LLM API key not configured")
if not auth_context.organization_id:
raise HTTPException(status_code=400, detail="Organization scope is required")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/api/prompts.py` around lines 144 - 146, The error message is
misleading when auth_context.organization_id is missing; update the
HTTPException raised in the branch that checks auth_context.organization_id to
use an org-scoped message (e.g. "organization context not configured" or
"organization ID not provided") instead of "LLM API key not configured" so
clients can distinguish missing org scope; locate the check referencing
auth_context.organization_id and change the HTTPException detail accordingly
while keeping status_code=400.

- 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 자체보다 맥락 종합, 판단 포인트, 실행 항목을 앞세운다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace machine-local absolute path with repo-relative path.

Line 21 hardcodes /home/seongho/..., which is not portable across contributors/environments.

Proposed fix
-- 디자인 원본은 `/home/seongho/ai_email_client/frontend/branding/uiux/`이고, 사용자-facing copy는 AI 자체보다 맥락 종합, 판단 포인트, 실행 항목을 앞세운다.
+- 디자인 원본은 `frontend/branding/uiux/`이고, 사용자-facing copy는 AI 자체보다 맥락 종합, 판단 포인트, 실행 항목을 앞세운다.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- 디자인 원본은 `/home/seongho/ai_email_client/frontend/branding/uiux/`이고, 사용자-facing copy는 AI 자체보다 맥락 종합, 판단 포인트, 실행 항목을 앞세운다.
- 디자인 원본은 `frontend/branding/uiux/`이고, 사용자-facing copy는 AI 자체보다 맥락 종합, 판단 포인트, 실행 항목을 앞세운다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/plans/2026-05-15-dashboard-mobile-rbac-product-remediation.md` at line
21, The document contains a machine-local absolute path
'/home/seongho/ai_email_client/frontend/branding/uiux/' which is not portable;
replace that string with a repo-relative path (for example
'frontend/branding/uiux/' or './frontend/branding/uiux/') or use a documented
variable/placeholder (e.g., '<repo-root>/frontend/branding/uiux/') so
contributors can resolve it in any environment and update the reference in the
same line where the absolute path appears.

Comment on lines +36 to +45
const loadAccessState = async () => {
await apiClient.ensureWorkspaceSettingsAccessReady();
if (!active) return;
setAccessState({
ready: apiClient.isWorkspaceSettingsAccessReady(),
canManage: apiClient.canManageWorkspaceSettings(),
});
};

void loadAccessState();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle readiness-fetch failures so the page doesn’t get stuck in perpetual loading.

At Line 37, a rejection from ensureWorkspaceSettingsAccessReady() is uncaught. That can leave accessState.ready false forever and trap users on “권한을 확인하고 있습니다.”.

Proposed fix
   const loadAccessState = async () => {
-    await apiClient.ensureWorkspaceSettingsAccessReady();
-    if (!active) return;
-    setAccessState({
-      ready: apiClient.isWorkspaceSettingsAccessReady(),
-      canManage: apiClient.canManageWorkspaceSettings(),
-    });
+    try {
+      await apiClient.ensureWorkspaceSettingsAccessReady();
+      if (!active) return;
+      setAccessState({
+        ready: apiClient.isWorkspaceSettingsAccessReady(),
+        canManage: apiClient.canManageWorkspaceSettings(),
+      });
+    } catch {
+      if (!active) return;
+      // Fail closed on permission, but avoid infinite loading state.
+      setAccessState({
+        ready: true,
+        canManage: false,
+      });
+    }
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app/prompt-studio/page.tsx` around lines 36 - 45, The call to
apiClient.ensureWorkspaceSettingsAccessReady() inside loadAccessState can reject
and is currently uncaught, which may leave the UI stuck; wrap the await in a
try/catch (or try/catch/finally) around ensureWorkspaceSettingsAccessReady() in
loadAccessState, and in the catch set a safe accessState (e.g., ready:
apiClient.isWorkspaceSettingsAccessReady() || true/false and canManage:
apiClient.canManageWorkspaceSettings() || false) or otherwise ensure
setAccessState is always called so the loading UI clears; reference the
loadAccessState function and the methods
apiClient.ensureWorkspaceSettingsAccessReady,
apiClient.isWorkspaceSettingsAccessReady, apiClient.canManageWorkspaceSettings,
and the setAccessState call when making the change.

Comment on lines +54 to +59

async def _create_connection(self, timeout: float | None):
if self.loop is None:
raise RuntimeError("No event loop set")

protocol = SMTPProtocol(loop=self.loop)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 _PinnedSMTP._create_connection — aiosmtplib 3.0.1 호환성 미검증

_create_connectionself.loop에 접근하고 SMTPProtocol(loop=self.loop)를 사용합니다. aiosmtplib 3.0.x에서는 Python 3.10의 loop 파라미터 제거 흐름에 따라 self.loop 속성이 없거나 SMTPProtocolloop= 인자를 받지 않을 수 있습니다.

더 결정적인 문제는, test_send_email_uses_validated_smtp_connect_ip_send_message_via_validated_smtp를 monkeypatch로 교체하기 때문에 _create_connection은 테스트 스위트에서 단 한 번도 실행되지 않습니다. SMTP 자격증명이 설정된 운영 환경에서 첫 번째 실제 발송 시도에서야 오류가 드러날 수 있습니다.

self.loop 대신 asyncio.get_running_loop()를 사용하고, _send_message_via_validated_smtp를 mocking하지 않는 통합 테스트를 추가하는 것을 권장합니다.

Comment thread backend/scripts/bootstrap_db.py
Comment on lines +53 to +60

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"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 require_provider_adminplatform_admin의 조직 범위 없는 접근을 차단

platform_admin 역할을 가진 사용자도 organization_id가 없으면 "Organization scope is required" 오류로 차단됩니다. OIDC 토큰에 organization_id 클레임이 없는 platform admin(예: 전역 관리자)은 이 엔드포인트에 접근할 수 없게 됩니다.

기존 check_admin_access는 역할만 확인했는데 이 추가 조건이 의도적인 변경인지 확인이 필요합니다. platform_admin에 대해서는 organization_id 체크를 면제하거나, 의도가 맞다면 주석으로 명시하는 것이 좋습니다.

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 30af8fce8da5e65aceac16db7d3d238fe109c514:

  • 22 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on 30af8fc.\n- Missing current-head CodeRabbit/coderabbitai evidence for 30af8fc.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 4f0229edf5346af32a8d5775bf90b3fd5e0093c2:

  • 22 unresolved current review thread(s) remain.\n- 3 required check(s) are not successful on 4f0229e.\n- Missing current-head CodeRabbit/coderabbitai evidence for 4f0229e.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 4f0229edf5346af32a8d5775bf90b3fd5e0093c2:

  • 22 unresolved current review thread(s) remain.\n- 2 required check(s) are not successful on 4f0229e.\n- Missing current-head CodeRabbit/coderabbitai evidence for 4f0229e.\n

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 4f0229edf5346af32a8d5775bf90b3fd5e0093c2:

  • 22 unresolved current review thread(s) remain.\n- 2 required check(s) are not successful on 4f0229e.\n- Missing current-head CodeRabbit/coderabbitai evidence for 4f0229e.\n

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/tests/test_repo_hygiene.py`:
- Around line 73-78: The test currently defines bracket_routes as a fixed list
of two exact file paths and asserts none exist; change it to detect any route
path that contains a bracketed path segment (e.g., [id], [...slug],
[slug]/nested) by scanning the app route tree and filtering paths whose parts
contain a segment that starts with '[' and ends with ']' (or use a regex like
r'\[.*\]')—replace the static REPO_ROOT /
"frontend/src/app/labels/[slug]/page.tsx" entries with a dynamic search (e.g.,
REPO_ROOT.joinpath("frontend","src","app").rglob("**/*")) and set bracket_routes
to the list of matches, then keep the existing assert not bracket_routes to fail
when any bracketed segment is present.

In `@frontend/src/app/labels/page.tsx`:
- Line 15: The direct call to decodeURIComponent(label) can throw a URIError on
malformed percent-encoding; replace the expression that returns
labelNames[label] ?? decodeURIComponent(label) with a guarded decode: try to run
decodeURIComponent(label) and return that result, but catch any exception and
fall back to the raw label (or labelNames[label] if present). Locate the return
expression using the labelNames and label symbols and wrap the decode in a
try-catch that returns a safe fallback on error.

In `@frontend/src/app/projects/page.tsx`:
- Line 13: The code calls decodeURIComponent(workspace) directly (used to render
workspace name via workspaceNames[workspace] ?? decodeURIComponent(workspace)),
which can throw for malformed percent-encoding from searchParams; wrap the
decode in a small safe-decoding guard (e.g., try/catch or a helper like
safeDecodeURIComponent) that returns the decoded string on success and falls
back to the original workspace (or a sanitized fallback) on error, then use that
safe value in place of decodeURIComponent(workspace).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 12da1d60-a5b8-4f0b-a8ef-dc29ba472668

📥 Commits

Reviewing files that changed from the base of the PR and between 30af8fc and 4f0229e.

📒 Files selected for processing (5)
  • backend/tests/test_repo_hygiene.py
  • frontend/src/app/labels/page.tsx
  • frontend/src/app/projects/page.tsx
  • frontend/src/app/route-contract.test.tsx
  • frontend/src/components/DashboardLayout.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • frontend/src/app/route-contract.test.tsx
  • frontend/src/components/DashboardLayout.tsx

Comment on lines +73 to +78
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()]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Broaden bracket-route detection to catch any bracket segment, not just [slug].

Lines 73–76 only check two exact files, so paths like [id], [...slug], or nested bracket segments under the same areas would bypass this hygiene guard.

💡 Proposed fix
-    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()]
+    app_targets = [
+        REPO_ROOT / "frontend/src/app/labels",
+        REPO_ROOT / "frontend/src/app/projects",
+    ]
+    bracket_routes = [
+        path
+        for base in app_targets
+        for path in base.rglob("page.tsx")
+        if any("[" in part and "]" in part for part in path.relative_to(base).parts)
+    ]
+
+    assert not bracket_routes, f"Bracket-path placeholders are not allowed: {bracket_routes}"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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()]
app_targets = [
REPO_ROOT / "frontend/src/app/labels",
REPO_ROOT / "frontend/src/app/projects",
]
bracket_routes = [
path
for base in app_targets
for path in base.rglob("page.tsx")
if any("[" in part and "]" in part for part in path.relative_to(base).parts)
]
assert not bracket_routes, f"Bracket-path placeholders are not allowed: {bracket_routes}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_repo_hygiene.py` around lines 73 - 78, The test currently
defines bracket_routes as a fixed list of two exact file paths and asserts none
exist; change it to detect any route path that contains a bracketed path segment
(e.g., [id], [...slug], [slug]/nested) by scanning the app route tree and
filtering paths whose parts contain a segment that starts with '[' and ends with
']' (or use a regex like r'\[.*\]')—replace the static REPO_ROOT /
"frontend/src/app/labels/[slug]/page.tsx" entries with a dynamic search (e.g.,
REPO_ROOT.joinpath("frontend","src","app").rglob("**/*")) and set bracket_routes
to the list of matches, then keep the existing assert not bracket_routes to fail
when any bracketed segment is present.

if (!label) {
return null;
}
return labelNames[label] ?? decodeURIComponent(label);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In JavaScript, does decodeURIComponent throw URIError for malformed percent-encoded input (e.g. "%E0%A4%A")? Please provide MDN/spec references.

💡 Result:

Yes. In JavaScript, decodeURIComponent throws a URIError with the message “malformed URI sequence” when the input contains malformed percent-encoding—specifically when a “%” is not followed by two hexadecimal digits, or when the percent-encoded bytes do not form a valid UTF-8 sequence [1]. MDN also gives the exact example decodeURIComponent("%E0%A4%A") throwing “URIError: malformed URI sequence” [1][2]. MDN/spec references: - decodeURIComponent MDN: “Thrown if encodedURI contains a % not followed by two hexadecimal digits, or if the escape sequence does not encode a valid UTF-8 character.” It also shows the try/catch example with "%E0%A4%A" producing URIError [1]. - URIError: malformed URI sequence MDN: explains that this exception occurs when URI encoding/decoding wasn’t successful and gives decodeURIComponent("%E0%A4%A") as a decoding example that throws [2].

Citations:


🏁 Script executed:

cat -n frontend/src/app/labels/page.tsx | head -30

Repository: Seongho-Bae/naruon

Length of output: 1212


Guard URL decoding to prevent runtime errors on malformed query input.

Line 15 calls decodeURIComponent(label) directly without error handling. Malformed percent-encoding in query parameters (e.g., ?label=%E0%A4%A) will throw a URIError and cause the page to fail with a 500 error. Wrap the call in a try-catch block.

💡 Proposed fix
 function resolveLabelName(label?: string) {
   if (!label) {
     return null;
   }
-  return labelNames[label] ?? decodeURIComponent(label);
+  if (labelNames[label]) {
+    return labelNames[label];
+  }
+  try {
+    return decodeURIComponent(label);
+  } catch {
+    return label;
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return labelNames[label] ?? decodeURIComponent(label);
function resolveLabelName(label?: string) {
if (!label) {
return null;
}
if (labelNames[label]) {
return labelNames[label];
}
try {
return decodeURIComponent(label);
} catch {
return label;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app/labels/page.tsx` at line 15, The direct call to
decodeURIComponent(label) can throw a URIError on malformed percent-encoding;
replace the expression that returns labelNames[label] ??
decodeURIComponent(label) with a guarded decode: try to run
decodeURIComponent(label) and return that result, but catch any exception and
fall back to the raw label (or labelNames[label] if present). Locate the return
expression using the labelNames and label symbols and wrap the decode in a
try-catch that returns a safe fallback on error.

if (!workspace) {
return null;
}
return workspaceNames[workspace] ?? decodeURIComponent(workspace);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In JavaScript, does decodeURIComponent throw URIError for malformed percent-encoded strings? Please cite MDN/spec.

💡 Result:

Yes. In JavaScript, decodeURIComponent throws a URIError (specifically “malformed URI sequence”) when the input contains malformed percent-encoded sequences—e.g., a “%” not followed by two hexadecimal digits, or an escape sequence that doesn’t encode a valid UTF-8 character.[1][2] MDN states that decodeURIComponent throws if the encoded string contains a “%” not followed by two hex digits or if the escape sequence does not encode a valid UTF-8 character.[1] MDN’s URIError (“malformed URI sequence”) page further explains that this error occurs when URI decoding wasn’t successful, showing an example decodeURIComponent("%E0%A4%A") that throws that URIError.[2] So for malformed percent-encoded strings, decodeURIComponent does throw URIError.

Citations:


🏁 Script executed:

cat -n frontend/src/app/projects/page.tsx

Repository: Seongho-Bae/naruon

Length of output: 1444


Protect workspace decoding from malformed query values.

Line 13 uses decodeURIComponent(workspace) without a guard; invalid percent-encoded query parameters throw URIError and crash the page. The workspace value comes from searchParams, which is untrusted user input.

Proposed fix
 function resolveWorkspaceName(workspace?: string) {
   if (!workspace) {
     return null;
   }
-  return workspaceNames[workspace] ?? decodeURIComponent(workspace);
+  if (workspaceNames[workspace]) {
+    return workspaceNames[workspace];
+  }
+  try {
+    return decodeURIComponent(workspace);
+  } catch {
+    return workspace;
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return workspaceNames[workspace] ?? decodeURIComponent(workspace);
function resolveWorkspaceName(workspace?: string) {
if (!workspace) {
return null;
}
if (workspaceNames[workspace]) {
return workspaceNames[workspace];
}
try {
return decodeURIComponent(workspace);
} catch {
return workspace;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/app/projects/page.tsx` at line 13, The code calls
decodeURIComponent(workspace) directly (used to render workspace name via
workspaceNames[workspace] ?? decodeURIComponent(workspace)), which can throw for
malformed percent-encoding from searchParams; wrap the decode in a small
safe-decoding guard (e.g., try/catch or a helper like safeDecodeURIComponent)
that returns the decoded string on success and falls back to the original
workspace (or a sanitized fallback) on error, then use that safe value in place
of decodeURIComponent(workspace).

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for b53a5f908841a45771d75189d3dcf62b74d57819:

  • 30 unresolved current review thread(s) remain.\n- 3 required check(s) are not successful on b53a5f9.\n- Missing current-head CodeRabbit/coderabbitai evidence for b53a5f9.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for b53a5f908841a45771d75189d3dcf62b74d57819:

  • 30 unresolved current review thread(s) remain.\n- 2 required check(s) are not successful on b53a5f9.\n- Missing current-head CodeRabbit/coderabbitai evidence for b53a5f9.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for b53a5f908841a45771d75189d3dcf62b74d57819:

  • 30 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on b53a5f9.\n- Missing current-head CodeRabbit/coderabbitai evidence for b53a5f9.\n

Comment on lines 178 to +190
)

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 send_email에서 이벤트 루프 블로킹 DNS 조회 발생

resolve_mail_server_connect_target은 내부적으로 socket.getaddrinfo를 동기적으로 호출합니다. 이 함수는 send_email이라는 async 함수 안에서 await 없이 직접 호출되므로, DNS 응답이 수백 ms~수 초 지연될 경우 전체 asyncio 이벤트 루프가 정지해 다른 모든 요청이 응답을 받지 못하게 됩니다.

mailbox_accounts.py 핸들러의 동일 문제(백로그 참조)와 같은 원인이며, 이 파트는 HTTP 요청 경로에서 직접 호출됩니다. asyncio.to_thread(resolve_mail_server_connect_target, "smtp", "SMTP", smtp_server, smtp_port)로 감싸 스레드풀로 분리해야 합니다.

Comment on lines +134 to +137
target = resolve_mail_server_connect_target(
"imap", "IMAP", imap_server, imap_port
)
except MailServerValidationError as exc:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 _sync_mailbox_account에서 동기 DNS 조회로 이벤트 루프 블로킹

resolve_mail_server_connect_target_resolve_safe_mail_server_ipssocket.getaddrinfo를 동기적으로 호출합니다. 이 코드가 async def _sync_mailbox_account 안에서 직접 실행되므로 DNS 응답이 지연되면 이벤트 루프 전체가 멈춥니다.

pop3_worker.py는 동일한 함수를 _do_pop3_sync라는 동기 메서드로 분리한 뒤 await asyncio.to_thread(self._do_pop3_sync, account)로 호출하는 올바른 패턴을 사용합니다. IMAP 워커도 동일한 패턴으로 수정하거나, 이 호출만 await asyncio.to_thread(resolve_mail_server_connect_target, ...) 로 감싸야 합니다.

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for b53a5f908841a45771d75189d3dcf62b74d57819:

  • 32 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on b53a5f9.\n- Missing current-head CodeRabbit/coderabbitai evidence for b53a5f9.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 5d83c74afa60f1784af1362cc02bac290efa86ae:

  • 32 unresolved current review thread(s) remain.\n- 2 required check(s) are not successful on 5d83c74.\n- Missing current-head CodeRabbit/coderabbitai evidence for 5d83c74.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 5d83c74afa60f1784af1362cc02bac290efa86ae:

  • 32 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on 5d83c74.\n- Missing current-head CodeRabbit/coderabbitai evidence for 5d83c74.\n

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 5d83c74afa60f1784af1362cc02bac290efa86ae:

  • 32 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on 5d83c74.\n- Missing current-head CodeRabbit/coderabbitai evidence for 5d83c74.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 5d83c74afa60f1784af1362cc02bac290efa86ae:

  • 30 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on 5d83c74.\n- Missing current-head CodeRabbit/coderabbitai evidence for 5d83c74.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for af70588520f92f6ad4b441a6417154228b6f8acb:

  • 30 unresolved current review thread(s) remain.\n- 3 required check(s) are not successful on af70588.\n- Missing current-head CodeRabbit/coderabbitai evidence for af70588.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for af70588520f92f6ad4b441a6417154228b6f8acb:

  • 29 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on af70588.\n- Missing current-head CodeRabbit/coderabbitai evidence for af70588.\n

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for af70588520f92f6ad4b441a6417154228b6f8acb:

  • 29 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on af70588.\n- Missing current-head CodeRabbit/coderabbitai evidence for af70588.\n

Comment on lines +120 to +128
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,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 포트 587/25/2525에서 SMTP 연결 실패 — use_tls=True 하드코딩

_send_message_via_validated_smtp는 항상 use_tls=True_PinnedSMTP를 생성합니다. 이는 포트 465(implicit TLS)에서만 유효하며, ALLOWED_MAIL_PORTS["smtp"]에 허용된 포트 587(STARTTLS), 25, 2525에서는 연결에 실패합니다. 포트 587은 현재 가장 널리 사용되는 SMTP submission 포트이므로, 587로 설정한 모든 메일함에서 실제 발송이 불가능해집니다. 포트 번호에 따라 use_tls/start_tls 플래그를 분기해야 합니다.

Suggested change
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,
)
_IMPLICIT_TLS_PORTS = {465}
use_implicit_tls = target.port in _IMPLICIT_TLS_PORTS
smtp = _PinnedSMTP(
hostname=target.host,
port=target.port,
connect_host=target.connect_host,
tls_server_hostname=target.host,
use_tls=use_implicit_tls,
start_tls=not use_implicit_tls,
username=username,
password=password,
)

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for af70588520f92f6ad4b441a6417154228b6f8acb:

  • 30 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on af70588.\n- Missing current-head CodeRabbit/coderabbitai evidence for af70588.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 69043f802fd893da1b7afb2a572147207094f27c:

  • 30 unresolved current review thread(s) remain.\n- 3 required check(s) are not successful on 69043f8.\n- Missing current-head CodeRabbit/coderabbitai evidence for 69043f8.\n

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 69043f802fd893da1b7afb2a572147207094f27c:

  • 30 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on 69043f8.\n- Missing current-head CodeRabbit/coderabbitai evidence for 69043f8.\n

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 69043f802fd893da1b7afb2a572147207094f27c:

  • 30 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on 69043f8.\n- Missing current-head CodeRabbit/coderabbitai evidence for 69043f8.\n

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
Comment thread backend/api/emails.py
Comment on lines +34 to +38
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 sanitize_email_body_for_response가 HTML 태그가 없는 경우 decoded_body 대신 원본 body를 반환합니다. 예를 들어 "AT&amp;T" 같이 HTML 엔티티는 있지만 <> 태그가 없는 평문 이메일 바디는 &amp; 가 그대로 노출됩니다. 분기 조건은 html.unescape 결과를 기준으로 하는데 반환값은 원본으로 되어 있어 불일치합니다.

Suggested change
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)
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 decoded_body
return sanitize_email_html_to_text(decoded_body)

@github-actions

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 69043f802fd893da1b7afb2a572147207094f27c:

  • 32 unresolved current review thread(s) remain.\n- 1 required check(s) are not successful on 69043f8.\n- Missing current-head CodeRabbit/coderabbitai evidence for 69043f8.\n

@seonghobae

Copy link
Copy Markdown
Contributor Author

Stale: OIDC integration has conflicts and CHANGES_REQUESTED. The auth boundary work has been redesigned in Phase 10+.

@github-actions

github-actions Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

PR governance metadata gate is not ready for 69043f802fd893da1b7afb2a572147207094f27c:

@seonghobae

Copy link
Copy Markdown
Contributor Author

Closing this PR as stale/unmergeable against current master.

Current evidence:

  • PR head 69043f8 is DIRTY/CONFLICTING with reviewDecision: CHANGES_REQUESTED.
  • The branch carries about 119 files and 12k insertions of old auth/mailbox/frontend/test changes; it cannot be safely merged as one current slice.
  • Current master plus merged feat: Phase 18 - Universal RBAC and Frontend UX fixes #229 already has newer signed-session auth, RBAC/ABAC, OIDC/JWKS preload, public identity header rejection, and naruon_session_token browser flow.
  • fix: reject JWT critical headers #231 separately handles current PyJWT/JWT crit hardening, so this branch should not carry stale PyJWT/auth fixes forward.
  • Current-head review threads remain unresolved across CodeRabbit, Greptile, and github-code-quality, including mailbox-scope correctness, blocking DNS, default reply race, NULL unique constraints, and stale frontend/auth fixtures.

Preserved follow-up:

  • Mailbox-aware scope is still a valid product direction, but it should be rebuilt as a fresh current-master PR with a small file set: mailbox account/scope APIs, mailbox routing, email/search integration, bootstrap/model changes, and focused tests.
  • DB-affecting evidence must include mocked tests plus a real PostgreSQL bootstrap/smoke path.

This close is stale PR queue cleanup, not a rejection of mailbox-aware scope.

@seonghobae seonghobae closed this May 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant