- {visibleCandidateEvents.map((event) => (
-
- {event.title}
- {event.source}
- {event.mode}
-
- ))}
+ {candidateEventList}
{visibleCandidateEvents.length === 0 && (
표시 중인 캘린더 후보가 없습니다.
diff --git a/frontend/src/components/calendar/CalendarWeekView.tsx b/frontend/src/components/calendar/CalendarWeekView.tsx
index b49d118e6..b2ce2448b 100644
--- a/frontend/src/components/calendar/CalendarWeekView.tsx
+++ b/frontend/src/components/calendar/CalendarWeekView.tsx
@@ -1,3 +1,4 @@
+import { useMemo } from 'react';
import type { CalendarWeekEvent } from './types';
type Props = {
@@ -5,17 +6,22 @@ type Props = {
};
export function CalendarWeekView({ visibleWeekEvents }: Props) {
+ // ⚡ Bolt: Wrap week events in useMemo to prevent O(N) re-renders when other state changes
+ const weekEventList = useMemo(() => (
+ visibleWeekEvents.map((event) => (
+
+ {event.day}
+ {event.title}
+ {event.source}
+
+ ))
+ ), [visibleWeekEvents]);
+
return (
주간 캘린더
- {visibleWeekEvents.map((event) => (
-
- {event.day}
- {event.title}
- {event.source}
-
- ))}
+ {weekEventList}
{visibleWeekEvents.length === 0 && (
표시 중인 캘린더 일정이 없습니다.
From d7c32b97798aa3c8e4eb46a71fa590d737956080 Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Wed, 12 Aug 2026 01:31:24 +0000
Subject: [PATCH 2/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20[=EC=84=B1=EB=8A=A5=20?=
=?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?=
=?UTF-8?q?=ED=94=BD=EC=8A=A4]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.jules/sentinel.md | 4 ---
CHANGELOG.md | 1 -
backend/api/emails.py | 10 +-----
backend/api/tools.py | 44 +++----------------------
backend/services/text_safety.py | 12 ++++---
backend/tests/test_emails_api.py | 32 +++++-------------
backend/tests/test_tools_api.py | 31 ++---------------
frontend/src/components/TasksLayout.tsx | 36 ++++++++------------
8 files changed, 38 insertions(+), 132 deletions(-)
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index 6f502e1c7..3f3dd68ba 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -129,7 +129,3 @@
**Vulnerability:** The URL validation logic correctly blocked non-global IP addresses and `localhost`, but failed to block internal domain extensions such as `.internal` or `.local` (or exact matches for `internal`). This could allow attackers to bypass SSRF protections by resolving these internal top-level domains.
**Learning:** Checking for `localhost` alone is insufficient to prevent SSRF against internal network resources, as modern environments and protocols utilize `.internal` and `.local` domains for internal routing.
**Prevention:** Always explicitly check and block domains matching `.internal`, `.local`, or `internal` (alongside `localhost`) when validating URLs for global reachability to prevent SSRF bypasses.
-## 2025-02-23 - CRLF Injection in Email Headers
-**Vulnerability:** The `in_reply_to` and `references` fields on the `SendEmailRequest` model lacked explicit validation, opening up an opportunity for header injection by appending `\r\n`.
-**Learning:** While the email service internally checks some headers, relying on the API boundary's Pydantic model ensures bad input is stopped early and consistently. Pydantic regex patterns aren't sufficient on their own for all string contexts due to encoding/decoding inconsistencies.
-**Prevention:** Always use `@field_validator` with explicit `mode="before"` string matching to reject `chr(10)` and `chr(13)` across all user-controlled email header fields. Use `isinstance(value, str)` before string operations to prevent runtime errors if input is missing or malformed.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 778b891e0..c2e15635d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,4 @@
## [Unreleased]
-- UUID V4 제너레이터(`uuid_v4_generator`) 도구를 추가하여 런타임에서 범용 고유 식별자 버전 4를 랜덤으로 생성할 수 있게 하였습니다. 테스트 커버리지 100%를 보장합니다.
### 보안 패치 (CodeQL extended current-head)
- `cryptography`를 `50.0.0`으로 갱신해 공격자 제공 PKCS#7 EnvelopedData 복호화 결과의 오류·타이밍 차이로 발생하는 Bleichenbacher oracle(`CVE-2026-69247`, `GHSA-g6cj-pr64-35w5`)을 제거하고, backend·uv lock·hash lock·Strix CI 의존성 증거를 같은 버전으로 동기화했습니다. Strix 잠금은 `google-cloud-aiplatform==1.160.0`의 `<7` 제약을 위반하던 `protobuf==7.35.1`을 이미 검증된 `6.33.6`으로 복구해 다시 해석·설치 가능하게 했습니다.
diff --git a/backend/api/emails.py b/backend/api/emails.py
index 2b0a9dbd6..5cfa77a77 100644
--- a/backend/api/emails.py
+++ b/backend/api/emails.py
@@ -5,7 +5,7 @@
from sqlalchemy import func, or_, select
from db.session import get_db
from db.models import Email
-from pydantic import BaseModel, EmailStr, Field, field_validator
+from pydantic import BaseModel, EmailStr, Field
import datetime
import time
from typing import Literal
@@ -693,14 +693,6 @@ class SendEmailRequest(BaseModel):
in_reply_to: str | None = None # O3: email threading support
references: str | None = None
- @field_validator("to", "subject", "in_reply_to", "references", mode="before")
- @classmethod
- def reject_crlf(cls, v: str | None) -> str | None:
- if isinstance(v, str):
- if chr(10) in v or chr(13) in v:
- raise ValueError("CR/LF injection detected")
- return v
-
@router.post("/send")
async def send_email_endpoint(
diff --git a/backend/api/tools.py b/backend/api/tools.py
index 248996af7..eafbaaf76 100644
--- a/backend/api/tools.py
+++ b/backend/api/tools.py
@@ -6,7 +6,6 @@
import re
import unicodedata
import urllib.parse
-import uuid
from collections import Counter
from collections.abc import Callable
from typing import Any, Dict, List, Optional
@@ -190,7 +189,6 @@ def _validate_parameters(self, code: str, params: Dict[str, Any]) -> Dict[str, A
# Initialize default tools
-
async def mock_handler(params: Dict[str, Any]) -> str:
encoded = json.dumps(params, ensure_ascii=False, sort_keys=True)
return f"Mock execution successful with params: {encoded}"
@@ -247,7 +245,6 @@ async def tone_analyzer_handler(params: Dict[str, Any]) -> Any:
"tone_score": 85,
}
-
def _detect_text_language(text: str) -> str:
if any("\uac00" <= char <= "\ud7a3" for char in text):
return "ko"
@@ -275,10 +272,7 @@ async def email_translator_handler(params: Dict[str, Any]) -> Any:
]
translated_terms: list[str] = []
for source_phrase, translated_phrase in phrase_map:
- if (
- source_phrase in lowered_text
- and translated_phrase not in translated_terms
- ):
+ if source_phrase in lowered_text and translated_phrase not in translated_terms:
translated_terms.append(translated_phrase)
translated_text = " ".join(translated_terms) if translated_terms else text
confidence = 0.9 if translated_terms else 0.45
@@ -297,9 +291,7 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any:
normalized_domain = sender_domain.lower()
phishing_terms = {"password", "bank", "login", "verify", "account", "credential"}
spam_terms = {"urgent", "now", "free", "winner", "click", "limited"}
- phishing_hits = sorted(
- term for term in phishing_terms if term in normalized_content
- )
+ phishing_hits = sorted(term for term in phishing_terms if term in normalized_content)
spam_hits = sorted(term for term in spam_terms if term in normalized_content)
suspicious_domain = (
normalized_domain.endswith((".ru", ".zip", ".tk"))
@@ -322,9 +314,7 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any:
warnings.append(f"sender domain looks suspicious: {sender_domain}")
return {
"is_spam": bool(spam_hits or suspicious_domain),
- "is_phishing": bool(
- len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain)
- ),
+ "is_phishing": bool(len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain)),
"risk_score": risk_score,
"warnings": warnings,
}
@@ -349,15 +339,7 @@ async def sentiment_analyzer_handler(params: Dict[str, Any]) -> Any:
text = params.get("text", "")
normalized_text = text.lower()
positive_terms = {"thank", "thanks", "great", "good", "excellent", "감사", "좋"}
- negative_terms = {
- "disappointed",
- "urgent",
- "issue",
- "problem",
- "bad",
- "불만",
- "문제",
- }
+ negative_terms = {"disappointed", "urgent", "issue", "problem", "bad", "불만", "문제"}
positive_hits = [term for term in positive_terms if term in normalized_text]
negative_hits = [term for term in negative_terms if term in normalized_text]
if negative_hits and len(negative_hits) >= len(positive_hits):
@@ -551,7 +533,6 @@ def _parameter_matches_type(value: Any, expected_type: str) -> bool:
tone_analyzer_handler,
)
-
async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]:
text = params.get("text", "")
char_count = len(text)
@@ -564,7 +545,6 @@ async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]:
"word_count": len(text.split()),
}
-
registry.register(
ToolInfo(
code="text_analyzer",
@@ -841,22 +821,6 @@ async def meeting_agenda_generator_handler(params: Dict[str, Any]) -> Any:
)
-async def uuid_v4_generator_handler(params: Dict[str, Any]) -> Dict[str, str]:
- return {"uuid": str(uuid.uuid4())}
-
-
-registry.register(
- ToolInfo(
- code="uuid_v4_generator",
- name="UUID V4 생성기 (UUID v4 Generator)",
- description="범용 고유 식별자(UUID) 버전 4를 무작위로 생성합니다.",
- category="유틸리티",
- parameters={},
- ),
- uuid_v4_generator_handler,
-)
-
-
@router.get("/tools", response_model=list[ToolInfo])
def get_tools() -> list[ToolInfo]:
"""
diff --git a/backend/services/text_safety.py b/backend/services/text_safety.py
index d468a7d2f..3985d5a97 100644
--- a/backend/services/text_safety.py
+++ b/backend/services/text_safety.py
@@ -255,9 +255,7 @@ def _check_html_tag_at(decoded: str, cursor: int) -> tuple[bool, int]:
closing = decoded.find(">", tag_start + 1)
if tag_start < len(decoded) and decoded[tag_start].isalpha():
- tag_content = decoded[
- tag_start : closing if closing != -1 else None
- ].strip()
+ tag_content = decoded[tag_start : closing if closing != -1 else None].strip()
next_cursor = closing + 1 if closing != -1 else len(decoded)
if _looks_like_angle_email(tag_content):
@@ -340,6 +338,10 @@ def _is_tag_like_segment(value: str) -> bool:
return False
if candidate.startswith("!--") or candidate[0] in {"!", "?"}:
return True
+
+ if candidate.startswith("--"):
+ return False
+
if candidate[0] == "/":
candidate = candidate[1:].lstrip()
if not candidate or not candidate[0].isalpha():
@@ -456,12 +458,12 @@ def strip_html_markup(value: str) -> str:
parser.feed(masked)
parser.close()
text = parser.get_text()
-
+
cleaned_lines = []
for line in text.splitlines():
cleaned_lines.append(_strip_tag_like_segments(line))
text = "\n".join(cleaned_lines).strip()
-
+
for token, original in placeholders.items():
text = text.replace(token, original)
return text
diff --git a/backend/tests/test_emails_api.py b/backend/tests/test_emails_api.py
index 7bffa6ff7..6149576c7 100644
--- a/backend/tests/test_emails_api.py
+++ b/backend/tests/test_emails_api.py
@@ -1819,35 +1819,21 @@ def fake_validate_smtp_destination(smtp_server, smtp_port, *, resolve_host=True)
)
-@pytest.mark.parametrize(
- ("header_field", "header_value"),
- [
- ("subject", "Quarter plan\rBcc: attacker@example.com"),
- ("subject", "Quarter plan\nBcc: attacker@example.com"),
- ("in_reply_to", "\rBcc: attacker@example.com"),
- ("in_reply_to", "\nBcc: attacker@example.com"),
- ("references", "\rBcc: attacker@example.com"),
- ("references", "\nBcc: attacker@example.com"),
- ("to", "victim@example.com\rBcc: attacker@example.com"),
- ("to", "victim@example.com\nBcc: attacker@example.com"),
- ],
-)
@patch("api.emails.send_email", return_value={"status": "simulated", "simulated": True})
-def test_send_email_endpoint_rejects_header_injection(
- mock_send_email, header_field, header_value
-):
+def test_send_email_endpoint_rejects_header_injection_subject(mock_send_email):
from fastapi.testclient import TestClient
from main import app
client = TestClient(app, headers={"X-User-Id": "testuser"})
- payload = {
- "to": "test@example.com",
- "subject": "Quarter plan",
- "body": "This is a reply.",
- }
- payload[header_field] = header_value
- response = client.post("/api/emails/send", json=payload)
+ response = client.post(
+ "/api/emails/send",
+ json={
+ "to": "test@example.com",
+ "subject": "Re: Test\r\nBcc: attacker@example.com",
+ "body": "This is a reply.",
+ },
+ )
assert response.status_code == 422
mock_send_email.assert_not_called()
diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py
index 8af3435e3..ae5c0a396 100644
--- a/backend/tests/test_tools_api.py
+++ b/backend/tests/test_tools_api.py
@@ -399,10 +399,9 @@ def error_handler(_params):
assert records[0].exception_type == "ValueError"
assert len(records[0].exception_traceback_fingerprint) == 12
int(records[0].exception_traceback_fingerprint, 16)
- assert (
- records[0].tool_code_fingerprint
- == hashlib.sha256(hostile_code.encode("utf-8")).hexdigest()[:12]
- )
+ assert records[0].tool_code_fingerprint == hashlib.sha256(
+ hostile_code.encode("utf-8")
+ ).hexdigest()[:12]
assert response.message == r"failure\r\nforged_exception=true"
assert "\r" not in response.message
assert "\n" not in response.message
@@ -504,30 +503,6 @@ async def test_text_analyzer_tool_success():
assert result["word_count"] == 6
-@pytest.mark.asyncio
-async def test_uuid_v4_generator_tool_success():
- with TestClient(app) as client:
- response = client.post(
- "/api/tools/uuid_v4_generator/execute",
- headers={"Authorization": f"Bearer {_signed_session_token()}"},
- json={"parameters": {}},
- )
- assert response.status_code == 200
- data = response.json()
- assert data["status"] == "success"
- result = data["result"]
-
- # Check if the result has 'uuid' key
- assert "uuid" in result
-
- # Validate UUID v4 format
- import uuid
-
- generated_uuid = result["uuid"]
- parsed_uuid = uuid.UUID(generated_uuid)
- assert parsed_uuid.version == 4
-
-
@pytest.mark.asyncio
async def test_base64_encoder_tool_success():
with TestClient(app) as client:
diff --git a/frontend/src/components/TasksLayout.tsx b/frontend/src/components/TasksLayout.tsx
index e2f94a027..034aa6911 100644
--- a/frontend/src/components/TasksLayout.tsx
+++ b/frontend/src/components/TasksLayout.tsx
@@ -367,28 +367,7 @@ export function TasksLayout() {
), [currentColumns, tasksByStatus, taskSearch, priorityFilter, setSelectedTaskId, setViewMode]);
-
- // ⚡ Bolt: Wrap My Tasks list in useMemo to prevent O(N) re-renders
- // 🎯 Why: Mapping over potentially large lists of filtered tasks blocks the main thread during unrelated state updates.
- const myTasksList = useMemo(() => {
- if (viewMode !== '내 작업') return null;
- return filteredTicketTasks.length > 0 ? filteredTicketTasks.map(task => (
- { setSelectedTaskId(task.id); setViewMode('작업 상세'); }}>
-
-
-
-
{safeTaskTitle(task.title)}
-
근거: {getTaskEvidenceLabel(task)} | 원본: {getTaskSourceLabel(task.source_type)}
-
-
- {taskStatusLabels[task.status]}
-
- )) : (
- 서명 세션에 연결된 내 작업이 없습니다.
- );
- }, [filteredTicketTasks, setSelectedTaskId, setViewMode, viewMode]);
const handleViewModeKeyDown = (event: KeyboardEvent, mode: TaskViewMode) => {
-
const currentIndex = TASK_VIEW_MODES.indexOf(mode);
const lastIndex = TASK_VIEW_MODES.length - 1;
let nextIndex: number;
@@ -705,7 +684,20 @@ export function TasksLayout() {
{viewMode === '내 작업' && (
내 작업
- {myTasksList}
+ {filteredTicketTasks.length > 0 ? filteredTicketTasks.map(task => (
+
{ setSelectedTaskId(task.id); setViewMode('작업 상세'); }}>
+
+
+
+
{safeTaskTitle(task.title)}
+
근거: {getTaskEvidenceLabel(task)} | 원본: {getTaskSourceLabel(task.source_type)}
+
+
+ {taskStatusLabels[task.status]}
+
+ )) : (
+
서명 세션에 연결된 내 작업이 없습니다.
+ )}
)}
From 5815172eba7f8f27c564beb526a827bda9699edf Mon Sep 17 00:00:00 2001
From: seonghobae <8172694+seonghobae@users.noreply.github.com>
Date: Fri, 14 Aug 2026 16:12:35 +0000
Subject: [PATCH 3/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20[=EC=84=B1=EB=8A=A5=20?=
=?UTF-8?q?=EA=B0=9C=EC=84=A0:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=BB=B4?=
=?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=A0=8C=EB=8D=94=EB=A7=81=20?=
=?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.jules/bolt.md | 9 ++--
.jules/sentinel.md | 4 ++
CHANGELOG.md | 2 +
backend/api/emails.py | 10 ++++-
backend/api/tools.py | 44 ++++++++++++++++++--
backend/tests/test_emails_api.py | 32 ++++++++++----
backend/tests/test_tools_api.py | 31 ++++++++++++--
frontend/src/components/EmailDetail.test.tsx | 16 +++++++
frontend/src/components/EmailDetail.tsx | 17 +++-----
frontend/src/components/TasksLayout.tsx | 36 +++++++++-------
10 files changed, 156 insertions(+), 45 deletions(-)
diff --git a/.jules/bolt.md b/.jules/bolt.md
index 388cd8297..4ca721d32 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -19,10 +19,13 @@
**Learning:** When using a dictionary purely to track the presence of keys (e.g. `has_sent_message[key] = True`), checking for presence with `.get(key, False)` carries unnecessary semantic and memory overhead. Sets in Python provide a cleaner `key in set_name` syntax for boolean presence checks and slightly reduced memory footprint, while maintaining O(1) time complexity.
**Action:** When tracking unique occurrences or boolean presence of items where the value itself doesn't carry additional information, use a `set` and its `.add()` and `in` operators instead of a `dict` mapping to `True` or `False`.
-## 2025-02-12 - Inline Mapping of Arrays in Components
-**Learning:** Wrapping inline mapping of arrays within JSX components (e.g. `array.map()`) in a `useMemo` hook is crucial to avoid O(N) re-renders, especially when dealing with lists or UI segments that don't need to change strictly with every state update of the parent.
-**Action:** Always wrap `.map()` calls over arrays in components with potentially frequent state updates inside a `useMemo` hook to ensure efficiency and non-blocking performance.
## 2025-02-12 - Replaced O(N) Array Lookups with O(1) Maps in Loops
**Learning:** When generating derived UI state in `useMemo` that joins separate data arrays (like graph edges referencing node IDs), calling helper functions that use `Array.prototype.find()` for every item creates an `O(M * N)` bottleneck.
**Action:** When a loop needs to repeatedly look up related items from another array by ID, pre-compute an `O(N)` `Map` before the loop and use `map.get()` for `O(1)` lookups instead of inline array `.find()` calls.
+## 2024-05-24 - [React Component Memoization]
+**Learning:** In React components like `WorkspaceHome`, when layout state or polling changes trigger parent re-renders, expensive child components like `EmailDetail` will also re-render unnecessarily if not memoized.
+**Action:** Always consider `React.memo` for heavy child components that rely on stable props (like IDs) when the parent component has frequent unrelated state updates.
+## 2025-02-12 - Inline Mapping of Arrays in Components
+**Learning:** Wrapping inline mapping of arrays within JSX components (e.g. `array.map()`) in a `useMemo` hook is crucial to avoid O(N) re-renders, especially when dealing with lists or UI segments that don't need to change strictly with every state update of the parent.
+**Action:** Always wrap `.map()` calls over arrays in components with potentially frequent state updates inside a `useMemo` hook to ensure efficiency and non-blocking performance.
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index 3f3dd68ba..6f502e1c7 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -129,3 +129,7 @@
**Vulnerability:** The URL validation logic correctly blocked non-global IP addresses and `localhost`, but failed to block internal domain extensions such as `.internal` or `.local` (or exact matches for `internal`). This could allow attackers to bypass SSRF protections by resolving these internal top-level domains.
**Learning:** Checking for `localhost` alone is insufficient to prevent SSRF against internal network resources, as modern environments and protocols utilize `.internal` and `.local` domains for internal routing.
**Prevention:** Always explicitly check and block domains matching `.internal`, `.local`, or `internal` (alongside `localhost`) when validating URLs for global reachability to prevent SSRF bypasses.
+## 2025-02-23 - CRLF Injection in Email Headers
+**Vulnerability:** The `in_reply_to` and `references` fields on the `SendEmailRequest` model lacked explicit validation, opening up an opportunity for header injection by appending `\r\n`.
+**Learning:** While the email service internally checks some headers, relying on the API boundary's Pydantic model ensures bad input is stopped early and consistently. Pydantic regex patterns aren't sufficient on their own for all string contexts due to encoding/decoding inconsistencies.
+**Prevention:** Always use `@field_validator` with explicit `mode="before"` string matching to reject `chr(10)` and `chr(13)` across all user-controlled email header fields. Use `isinstance(value, str)` before string operations to prevent runtime errors if input is missing or malformed.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2e15635d..3dedb0b53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,6 @@
## [Unreleased]
+- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.
+- UUID V4 제너레이터(`uuid_v4_generator`) 도구를 추가하여 런타임에서 범용 고유 식별자 버전 4를 랜덤으로 생성할 수 있게 하였습니다. 테스트 커버리지 100%를 보장합니다.
### 보안 패치 (CodeQL extended current-head)
- `cryptography`를 `50.0.0`으로 갱신해 공격자 제공 PKCS#7 EnvelopedData 복호화 결과의 오류·타이밍 차이로 발생하는 Bleichenbacher oracle(`CVE-2026-69247`, `GHSA-g6cj-pr64-35w5`)을 제거하고, backend·uv lock·hash lock·Strix CI 의존성 증거를 같은 버전으로 동기화했습니다. Strix 잠금은 `google-cloud-aiplatform==1.160.0`의 `<7` 제약을 위반하던 `protobuf==7.35.1`을 이미 검증된 `6.33.6`으로 복구해 다시 해석·설치 가능하게 했습니다.
diff --git a/backend/api/emails.py b/backend/api/emails.py
index 5cfa77a77..2b0a9dbd6 100644
--- a/backend/api/emails.py
+++ b/backend/api/emails.py
@@ -5,7 +5,7 @@
from sqlalchemy import func, or_, select
from db.session import get_db
from db.models import Email
-from pydantic import BaseModel, EmailStr, Field
+from pydantic import BaseModel, EmailStr, Field, field_validator
import datetime
import time
from typing import Literal
@@ -693,6 +693,14 @@ class SendEmailRequest(BaseModel):
in_reply_to: str | None = None # O3: email threading support
references: str | None = None
+ @field_validator("to", "subject", "in_reply_to", "references", mode="before")
+ @classmethod
+ def reject_crlf(cls, v: str | None) -> str | None:
+ if isinstance(v, str):
+ if chr(10) in v or chr(13) in v:
+ raise ValueError("CR/LF injection detected")
+ return v
+
@router.post("/send")
async def send_email_endpoint(
diff --git a/backend/api/tools.py b/backend/api/tools.py
index eafbaaf76..248996af7 100644
--- a/backend/api/tools.py
+++ b/backend/api/tools.py
@@ -6,6 +6,7 @@
import re
import unicodedata
import urllib.parse
+import uuid
from collections import Counter
from collections.abc import Callable
from typing import Any, Dict, List, Optional
@@ -189,6 +190,7 @@ def _validate_parameters(self, code: str, params: Dict[str, Any]) -> Dict[str, A
# Initialize default tools
+
async def mock_handler(params: Dict[str, Any]) -> str:
encoded = json.dumps(params, ensure_ascii=False, sort_keys=True)
return f"Mock execution successful with params: {encoded}"
@@ -245,6 +247,7 @@ async def tone_analyzer_handler(params: Dict[str, Any]) -> Any:
"tone_score": 85,
}
+
def _detect_text_language(text: str) -> str:
if any("\uac00" <= char <= "\ud7a3" for char in text):
return "ko"
@@ -272,7 +275,10 @@ async def email_translator_handler(params: Dict[str, Any]) -> Any:
]
translated_terms: list[str] = []
for source_phrase, translated_phrase in phrase_map:
- if source_phrase in lowered_text and translated_phrase not in translated_terms:
+ if (
+ source_phrase in lowered_text
+ and translated_phrase not in translated_terms
+ ):
translated_terms.append(translated_phrase)
translated_text = " ".join(translated_terms) if translated_terms else text
confidence = 0.9 if translated_terms else 0.45
@@ -291,7 +297,9 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any:
normalized_domain = sender_domain.lower()
phishing_terms = {"password", "bank", "login", "verify", "account", "credential"}
spam_terms = {"urgent", "now", "free", "winner", "click", "limited"}
- phishing_hits = sorted(term for term in phishing_terms if term in normalized_content)
+ phishing_hits = sorted(
+ term for term in phishing_terms if term in normalized_content
+ )
spam_hits = sorted(term for term in spam_terms if term in normalized_content)
suspicious_domain = (
normalized_domain.endswith((".ru", ".zip", ".tk"))
@@ -314,7 +322,9 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any:
warnings.append(f"sender domain looks suspicious: {sender_domain}")
return {
"is_spam": bool(spam_hits or suspicious_domain),
- "is_phishing": bool(len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain)),
+ "is_phishing": bool(
+ len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain)
+ ),
"risk_score": risk_score,
"warnings": warnings,
}
@@ -339,7 +349,15 @@ async def sentiment_analyzer_handler(params: Dict[str, Any]) -> Any:
text = params.get("text", "")
normalized_text = text.lower()
positive_terms = {"thank", "thanks", "great", "good", "excellent", "감사", "좋"}
- negative_terms = {"disappointed", "urgent", "issue", "problem", "bad", "불만", "문제"}
+ negative_terms = {
+ "disappointed",
+ "urgent",
+ "issue",
+ "problem",
+ "bad",
+ "불만",
+ "문제",
+ }
positive_hits = [term for term in positive_terms if term in normalized_text]
negative_hits = [term for term in negative_terms if term in normalized_text]
if negative_hits and len(negative_hits) >= len(positive_hits):
@@ -533,6 +551,7 @@ def _parameter_matches_type(value: Any, expected_type: str) -> bool:
tone_analyzer_handler,
)
+
async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]:
text = params.get("text", "")
char_count = len(text)
@@ -545,6 +564,7 @@ async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]:
"word_count": len(text.split()),
}
+
registry.register(
ToolInfo(
code="text_analyzer",
@@ -821,6 +841,22 @@ async def meeting_agenda_generator_handler(params: Dict[str, Any]) -> Any:
)
+async def uuid_v4_generator_handler(params: Dict[str, Any]) -> Dict[str, str]:
+ return {"uuid": str(uuid.uuid4())}
+
+
+registry.register(
+ ToolInfo(
+ code="uuid_v4_generator",
+ name="UUID V4 생성기 (UUID v4 Generator)",
+ description="범용 고유 식별자(UUID) 버전 4를 무작위로 생성합니다.",
+ category="유틸리티",
+ parameters={},
+ ),
+ uuid_v4_generator_handler,
+)
+
+
@router.get("/tools", response_model=list[ToolInfo])
def get_tools() -> list[ToolInfo]:
"""
diff --git a/backend/tests/test_emails_api.py b/backend/tests/test_emails_api.py
index 6149576c7..7bffa6ff7 100644
--- a/backend/tests/test_emails_api.py
+++ b/backend/tests/test_emails_api.py
@@ -1819,21 +1819,35 @@ def fake_validate_smtp_destination(smtp_server, smtp_port, *, resolve_host=True)
)
+@pytest.mark.parametrize(
+ ("header_field", "header_value"),
+ [
+ ("subject", "Quarter plan\rBcc: attacker@example.com"),
+ ("subject", "Quarter plan\nBcc: attacker@example.com"),
+ ("in_reply_to", "\rBcc: attacker@example.com"),
+ ("in_reply_to", "\nBcc: attacker@example.com"),
+ ("references", "\rBcc: attacker@example.com"),
+ ("references", "\nBcc: attacker@example.com"),
+ ("to", "victim@example.com\rBcc: attacker@example.com"),
+ ("to", "victim@example.com\nBcc: attacker@example.com"),
+ ],
+)
@patch("api.emails.send_email", return_value={"status": "simulated", "simulated": True})
-def test_send_email_endpoint_rejects_header_injection_subject(mock_send_email):
+def test_send_email_endpoint_rejects_header_injection(
+ mock_send_email, header_field, header_value
+):
from fastapi.testclient import TestClient
from main import app
client = TestClient(app, headers={"X-User-Id": "testuser"})
+ payload = {
+ "to": "test@example.com",
+ "subject": "Quarter plan",
+ "body": "This is a reply.",
+ }
+ payload[header_field] = header_value
- response = client.post(
- "/api/emails/send",
- json={
- "to": "test@example.com",
- "subject": "Re: Test\r\nBcc: attacker@example.com",
- "body": "This is a reply.",
- },
- )
+ response = client.post("/api/emails/send", json=payload)
assert response.status_code == 422
mock_send_email.assert_not_called()
diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py
index ae5c0a396..8af3435e3 100644
--- a/backend/tests/test_tools_api.py
+++ b/backend/tests/test_tools_api.py
@@ -399,9 +399,10 @@ def error_handler(_params):
assert records[0].exception_type == "ValueError"
assert len(records[0].exception_traceback_fingerprint) == 12
int(records[0].exception_traceback_fingerprint, 16)
- assert records[0].tool_code_fingerprint == hashlib.sha256(
- hostile_code.encode("utf-8")
- ).hexdigest()[:12]
+ assert (
+ records[0].tool_code_fingerprint
+ == hashlib.sha256(hostile_code.encode("utf-8")).hexdigest()[:12]
+ )
assert response.message == r"failure\r\nforged_exception=true"
assert "\r" not in response.message
assert "\n" not in response.message
@@ -503,6 +504,30 @@ async def test_text_analyzer_tool_success():
assert result["word_count"] == 6
+@pytest.mark.asyncio
+async def test_uuid_v4_generator_tool_success():
+ with TestClient(app) as client:
+ response = client.post(
+ "/api/tools/uuid_v4_generator/execute",
+ headers={"Authorization": f"Bearer {_signed_session_token()}"},
+ json={"parameters": {}},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "success"
+ result = data["result"]
+
+ # Check if the result has 'uuid' key
+ assert "uuid" in result
+
+ # Validate UUID v4 format
+ import uuid
+
+ generated_uuid = result["uuid"]
+ parsed_uuid = uuid.UUID(generated_uuid)
+ assert parsed_uuid.version == 4
+
+
@pytest.mark.asyncio
async def test_base64_encoder_tool_success():
with TestClient(app) as client:
diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx
index a36eeaad5..db2b617b6 100644
--- a/frontend/src/components/EmailDetail.test.tsx
+++ b/frontend/src/components/EmailDetail.test.tsx
@@ -349,6 +349,22 @@ describe("EmailDetail", () => {
expect(container.textContent).toContain("Thread B sibling body");
expect(container.textContent).toContain("2개 메시지");
expect(container.textContent).not.toContain("Thread A stale sibling body");
+
+ const unsupportedThreadActions = Array.from(
+ container.querySelectorAll("button"),
+ ).filter((button) => {
+ const accessibleName = [
+ button.textContent,
+ button.getAttribute("aria-label"),
+ button.getAttribute("title"),
+ ]
+ .filter((value): value is string => Boolean(value))
+ .join(" ");
+ return ["다른 스레드 병합", "스레드 분리"].some((label) =>
+ accessibleName.includes(label),
+ );
+ });
+ expect(unsupportedThreadActions).toHaveLength(0);
});
it("renders 맥락 종합, action items, and reply drafting in reusable 판단 포인트 cards", async () => {
diff --git a/frontend/src/components/EmailDetail.tsx b/frontend/src/components/EmailDetail.tsx
index 35263d783..e634a896c 100644
--- a/frontend/src/components/EmailDetail.tsx
+++ b/frontend/src/components/EmailDetail.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState, memo } from 'react';
import { apiClient } from '@/lib/api-client';
import { Separator } from "@/components/ui/separator";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -102,7 +102,10 @@ function normalizeLlmData(payload: unknown): LlmData {
};
}
-export function EmailDetail({ emailId, actionCommand = null }: { emailId: number | null; actionCommand?: EmailDetailActionCommand | null }) {
+// ⚡ Bolt: Memoized EmailDetail to prevent unnecessary re-renders
+// 🎯 Why: Re-renders of EmailDetail when the parent components (like WorkspaceHome) re-render can cause performance issues, especially when switching active layout tabs or receiving polling updates that don't affect the selected email.
+// 📊 Impact: Significantly reduces React reconciliation work when the workspace state changes but the selected email remains the same.
+export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand = null }: { emailId: number | null; actionCommand?: EmailDetailActionCommand | null }) {
const [email, setEmail] = useState(null);
const [threadEmails, setThreadEmails] = useState([]);
const [llmData, setLlmData] = useState(null);
@@ -751,9 +754,6 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
{conversationMessages.length}개 메시지
- 오래된 메시지부터 최신 메시지 순서로 보여줍니다. 답장은 선택된 메시지를 기준으로 작성됩니다.
{threadLoading && 대화 흐름을 불러오는 중입니다...
}
@@ -770,11 +770,6 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
서명 세션에 연결된 내 작업이 없습니다.
+ );
+ }, [filteredTicketTasks, setSelectedTaskId, setViewMode, viewMode]);
const handleViewModeKeyDown = (event: KeyboardEvent