diff --git a/AGENTS.md b/AGENTS.md
index 9104dd1f4..0ae1109c5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -477,6 +477,10 @@ in this repo.
the visible app language; do not rely on the SVG icon alone for Calendar,
Tasks, drawer, modal, or toolbar actions.
- Execution steps resulting in `Timeout`, `Fatal`, `Warn`, or `Denied` outputs are considered hard failures. Tests must run without these warnings to be considered passing.
+- React 테스트의 상태 갱신 입력은 나중의 버튼 클릭뿐 아니라 native value
+ setter와 `dispatchEvent`도 `await act` 안에서 실행한다. 진단 spy는 원래
+ 출력을 유지하고 `act` 경고가 없는지 단언한다. 테스트를 통과시키려고
+ `console.error`를 끄지 않는다.
- Strix success artifacts must also be scanned for `Timeout`, `Fatal`, `Warn`,
or `Denied` output before accepting clean evidence. Filter only narrowly known
third-party Strix internal warnings, such as the
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7ec84c36f..82bb6a407 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,12 @@
## [Unreleased]
+### EmailDetail 반응형 실행 표면
+
+- 참여자와 첨부파일 증거를 모바일·데스크톱에서 동일하게 확인할 수 있도록 반응형 스크롤 레일과 명시적 접근성 이름을 추가했습니다.
+- 일정 충돌 패널의 `일정 조율` 버튼을 기존 calendar writeback intent에 연결하고, 서명된 서버 목록에서 사용자가 명시적으로 선택한 opaque `target_source_id`만 전송하며, source conflict 재확인·부분 실패·loading·disabled·live-status 상태를 검증합니다.
+- UI PR에 섞인 thread ID, SMTP allowlist, `.msg` import, tenant scope backend 변경은 정확한 `develop` 기준으로 제거했습니다.
+
+### 의미 단위 임베딩
+
- 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.
diff --git a/docs/doctoring/email-detail-responsive-action-surface.md b/docs/doctoring/email-detail-responsive-action-surface.md
new file mode 100644
index 000000000..607bcb404
--- /dev/null
+++ b/docs/doctoring/email-detail-responsive-action-surface.md
@@ -0,0 +1,114 @@
+# EmailDetail responsive action surface doctoring
+
+## Decision
+
+The email detail view exposes participants and attachment names at every viewport
+size. Attachments use a horizontally scrollable, explicitly named region so a
+small viewport does not silently remove source evidence. The meeting-conflict
+panel reuses the existing calendar writeback-intent handler rather than rendering
+an inert call-to-action. The user must select an opaque source returned by the
+signed server registry before the action is enabled; every intent carries that
+exact `target_source_id`, and a source conflict clears the selection so the user
+must confirm the current source again. Mixed batch outcomes preserve successful
+intents, report the failed count, and never relabel a source identifier as a
+provider calendar-event identifier. Loading, disabled, and polite live-status
+states remain in the same product surface.
+
+Calendar-source state is keyed to the active email and actionable summary
+context. A navigation or summary-context change therefore derives an empty,
+non-confirmed loading or idle view immediately instead of reusing a source from a
+previous email. Registry success or failure publishes state only from the still
+mounted request for that exact context; stale requests cannot reactivate an old
+selection. This fail-closed lifecycle also avoids synchronous state resets inside
+the React effect while preserving explicit confirmation.
+
+Unrelated backend changes are excluded from this UI slice. Thread identifier,
+SMTP destination, import-format, and tenant-scope policy changes require their
+own security rationale and regression contracts rather than hitchhiking on a
+presentation PR.
+
+## Accessibility boundary
+
+The implementation preserves native button semantics and the repository's
+keyboard-visible focus system, gives the attachment evidence region an
+accessible name, and exposes asynchronous status through `role=status` and
+`aria-live=polite`. WCAG 2.2 is used as the current normative target. The focused
+regression proves discoverability and activation in the DOM, but this record does
+not claim full WCAG conformance without contrast, zoom, assistive-technology, and
+manual usability evidence.
+
+## Verification contract
+
+- The participant list renders without an unsafe type assertion.
+- The attachment rail is present and not hidden on small viewports.
+- The meeting action is disabled when no extracted action item exists, while the
+ request is pending, or until one current server-authorized source is confirmed.
+- Activating the meeting action sends the exact opaque `target_source_id` with
+ every writeback-intent request.
+- A `409` source conflict clears confirmation and requires explicit reselection.
+- Source state is never reused across email or actionable-summary context keys,
+ and an unmounted registry request cannot publish stale state.
+- Complete and partial batches produce distinct polite status evidence, and
+ analytics never treat `target_source_id` as a provider event identifier.
+- The three unrelated backend files are byte-identical to the exact PR base.
+- Frontend focused tests, full tests, lint, type checking, coverage collection,
+ and production build run before the verified commit is published.
+
+## 입력 이벤트와 Hook 의존성 경고 수리
+
+PR #1245의 `796b34c5a1322f09c6f00b8cf24591ae04b89b6b`에서 EmailDetail
+테스트 26개는 React `act` 경고 4개를 출력하고도 exit 0으로 끝났다.
+초안 지시 입력, 초안 지우기, 전송 성공·실패 테스트가 native value setter와
+입력 이벤트를 `act` 밖에서 실행했고, 이후 버튼 클릭만 감쌌다. 입력 자체가
+React 상태 갱신을 예약하므로 나중의 클릭을 감싸는 것으로는 해결되지 않는다.
+이전 커밋의 재현 근거는 다음 기록에 남아 있다.
+https://github.com/ContextualWisdomLab/naruon/pull/1244#issuecomment-5559885208
+
+원래 출력을 유지하는 `console.error` spy로 각 테스트의 정리 단계까지
+`not wrapped in act` 발생 여부를 검사하고, 단언 전에 console을 복원한다.
+검사만 추가했을 때 4개 실패·22개 통과로 재현됐다. 기존 setter/event 쌍
+4개를 `await act(async () => ...)`로 감싼 뒤에는 EmailDetail과
+calendar-writeback 테스트 30개가 해당 경고 없이 통과했다. 실패 경로의
+API 오류 로그는 그대로 출력하며, 경고를 숨겨 통과시키지 않는다.
+
+이후 lint에서 같은 컴포넌트의 `setSelectedWritebackSourceId` 의존성 누락을
+발견했다. 일반 lint는 경고 1개와 exit 0을 반환했지만 `--max-warnings 0`은
+exit 1로 실패했다. 이 함수는 안정적인 React state setter가 아니라
+`sourceContextKey`와 `sourceContextIsActionable`에 따라 바뀌는 콜백이다.
+일정 반영 콜백이 현재 선택 문맥을 사용하도록 의존성 목록에 추가한다.
+공급자 호출 계약이나 CalendarPage 테스트는 바꾸지 않는다.
+
+이미 사용하는 React 도구로 해결하므로 새 상호작용 라이브러리나 공통
+래퍼는 추가하지 않는다. 이 수리는 실제 브라우저 동작, 전체 테스트의 모든
+경고 제거, 최신 Visual Inspection 또는 보호 병합 완료를 뜻하지 않는다.
+Context7은 사용량 한도로 조회에 실패해 아래 React 공식 문서의 비동기
+이벤트 처리 지침을 확인했다.
+
+`frontend`에서 실행할 검증 명령:
+
+```sh
+corepack pnpm exec vitest run src/components/EmailDetail.test.tsx src/components/EmailDetail.calendar-writeback.test.tsx
+corepack pnpm exec eslint src/components/EmailDetail.tsx src/components/EmailDetail.test.tsx --max-warnings 0
+```
+
+수리 후 위 테스트 30개와 전체 `eslint . --max-warnings 0`, 타입 검사,
+프로덕션 빌드는 exit 0이다. 전체 커버리지 실행은 52파일·442테스트가
+통과했지만 별도 CalendarLayout `act` 경고 3개는 남았다. 커버리지는
+lines 86.76%, statements 84.06%, functions 85.67%, branches 75.18%로
+100% 목표를 충족하지 못한다. 측정 대상을 줄이거나 남은 경고를 숨기지
+않으며, 이 결과를 전체 제품의 검증 완료로 표시하지 않는다.
+
+## References
+
+React. (n.d.). *act*. https://react.dev/reference/react/act
+
+World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines
+(WCAG) 2.2*. https://www.w3.org/TR/WCAG22/
+
+World Wide Web Consortium. (n.d.). *Understanding success criterion 2.4.7:
+Focus visible*. Retrieved August 5, 2026, from
+https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html
+
+World Wide Web Consortium. (n.d.). *Understanding success criterion 4.1.3:
+Status messages*. Retrieved August 5, 2026, from
+https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html
diff --git a/frontend/src/components/EmailDetail.calendar-writeback.test.tsx b/frontend/src/components/EmailDetail.calendar-writeback.test.tsx
new file mode 100644
index 000000000..23e280e50
--- /dev/null
+++ b/frontend/src/components/EmailDetail.calendar-writeback.test.tsx
@@ -0,0 +1,333 @@
+/* @vitest-environment jsdom */
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/components/ui/separator", () => ({ Separator: () =>
}));
+vi.mock("@/components/ui/avatar", () => ({
+ Avatar: ({ children }: { children: React.ReactNode }) => {children}
,
+ AvatarFallback: ({ children }: { children: React.ReactNode }) => {children} ,
+}));
+vi.mock("@/components/ui/scroll-area", () => ({
+ ScrollArea: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+vi.mock("@/components/ui/badge", () => ({
+ Badge: ({ children }: { children: React.ReactNode }) => {children} ,
+}));
+vi.mock("@/components/ui/checkbox", () => ({
+ Checkbox: (props: React.InputHTMLAttributes) => ,
+}));
+vi.mock("@/components/ui/button", () => ({
+ Button: ({ children, ...props }: React.ButtonHTMLAttributes) => (
+ {children}
+ ),
+}));
+vi.mock("@/components/ui/textarea", () => ({
+ Textarea: (props: React.TextareaHTMLAttributes) => ,
+}));
+vi.mock("@/components/ui/input", () => ({
+ Input: (props: React.InputHTMLAttributes) => ,
+}));
+vi.mock("lucide-react", () => ({
+ MessagesSquare: () => ,
+ Loader2: () => ,
+}));
+vi.mock("@/components/DecisionPointCard", () => ({
+ DecisionPointCard: ({
+ children,
+ footerActions,
+ title,
+ }: {
+ children?: React.ReactNode;
+ footerActions?: React.ReactNode;
+ title: string;
+ }) => (
+
+ {children}
+ {footerActions}
+
+ ),
+}));
+vi.mock("@/components/SourceDrawer", () => ({ SourceDrawer: () => null }));
+
+import { EmailDetail } from "./EmailDetail";
+import {
+ clearRecordedProductEvents,
+ getRecordedProductEvents,
+} from "@/lib/product-events";
+
+type Deferred = {
+ promise: Promise;
+ resolve: (value: T) => void;
+};
+
+const EMAIL = {
+ id: 30,
+ message_id: "",
+ thread_id: null,
+ sender: "sender@example.com",
+ recipients: "user@example.com",
+ subject: "Calendar source",
+ date: "2026-05-18T10:00:00Z",
+ body: "Coordinate the meeting",
+ schedule_conflict: true,
+ attachments: ["proposal.pdf"],
+};
+
+const SOURCES = [
+ {
+ source_id: "caldav_source_primary",
+ provider: "Fastmail",
+ protocol: "caldav",
+ owner_id: "owner_primary",
+ organization_id: null,
+ capabilities: ["read", "write", "etag"],
+ writeback_enabled: true,
+ etag: "etag-primary",
+ },
+ {
+ source_id: "caldav_source_secondary",
+ provider: "Nextcloud",
+ protocol: "caldav",
+ owner_id: "owner_secondary",
+ organization_id: null,
+ capabilities: ["read", "write", "etag"],
+ writeback_enabled: true,
+ etag: "etag-secondary",
+ },
+];
+
+function deferred(): Deferred {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((innerResolve) => {
+ resolve = innerResolve;
+ });
+ return { promise, resolve };
+}
+
+function jsonResponse(body: unknown, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+function intentResponse(sourceId: string) {
+ return {
+ workspace_id: "workspace_primary",
+ target_source_id: sourceId,
+ protocol: "caldav",
+ writeback_mode: "customer_owned",
+ requires_if_match: false,
+ if_match: null,
+ provenance: { source_provider: sourceId === SOURCES[0].source_id ? "Fastmail" : "Nextcloud" },
+ audit_event: "calendar_writeback_intent_created",
+ provider_write_executed: false,
+ status: "intent_created",
+ runner_request_id: null,
+ provider_status: null,
+ error_code: null,
+ };
+}
+
+async function flushAsyncWork() {
+ for (let index = 0; index < 5; index += 1) {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ }
+}
+
+function findButton(container: HTMLElement, label: string) {
+ return Array.from(container.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes(label),
+ );
+}
+
+function selectCalendarSource(container: HTMLElement, sourceId: string) {
+ const sourceSelect = container.querySelector('select[aria-label="일정 원본 선택"]');
+ expect(sourceSelect).not.toBeNull();
+ act(() => {
+ if (!sourceSelect) return;
+ sourceSelect.value = sourceId;
+ sourceSelect.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ return sourceSelect;
+}
+
+describe("EmailDetail calendar writeback boundary", () => {
+ let root: Root | null = null;
+ let container: HTMLDivElement | null = null;
+
+ afterEach(() => {
+ if (root) act(() => root?.unmount());
+ root = null;
+ container?.remove();
+ container = null;
+ vi.unstubAllGlobals();
+ clearRecordedProductEvents();
+ });
+
+ async function renderWithFetch(fetchMock: ReturnType) {
+ vi.stubGlobal("fetch", fetchMock);
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ await act(async () => {
+ root?.render( );
+ });
+ await flushAsyncWork();
+ return container;
+ }
+
+ it("requires an explicit server-authorized source and sends its opaque identifier", async () => {
+ const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith(`/api/emails/${EMAIL.id}`)) return Promise.resolve(jsonResponse(EMAIL));
+ if (url.endsWith("/api/llm/summarize")) {
+ return Promise.resolve(jsonResponse({ summary: "Summary", action_items: ["Review the meeting"] }));
+ }
+ if (url.endsWith("/api/calendar/writeback-sources")) return Promise.resolve(jsonResponse(SOURCES));
+ if (url.endsWith("/api/calendar/writeback-intent") && init?.method === "POST") {
+ const payload = JSON.parse(String(init.body)) as { target_source_id?: string };
+ return Promise.resolve(jsonResponse(intentResponse(String(payload.target_source_id))));
+ }
+ return Promise.resolve(jsonResponse({}));
+ });
+
+ const rendered = await renderWithFetch(fetchMock);
+ const attachmentRegion = rendered.querySelector('[role="region"][aria-label="첨부파일"]');
+ expect(attachmentRegion).not.toBeNull();
+
+ const sourceSelect = rendered.querySelector('select[aria-label="일정 원본 선택"]');
+ expect(sourceSelect?.value).toBe("");
+ const scheduleButton = findButton(rendered, "일정 조율");
+ expect(scheduleButton?.disabled).toBe(true);
+
+ selectCalendarSource(rendered, SOURCES[1].source_id);
+ expect(scheduleButton?.disabled).toBe(false);
+ await act(async () => {
+ scheduleButton?.click();
+ await flushAsyncWork();
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ "/api/calendar/writeback-intent",
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ action: "create",
+ summary: "Review the meeting",
+ target_source_id: SOURCES[1].source_id,
+ }),
+ }),
+ );
+ expect(rendered.textContent).toContain("1개 일정 반영 의도를 Nextcloud 원본에 요청했습니다.");
+ });
+
+ it("keeps scheduling disabled with no action items and while a request is pending", async () => {
+ const writeback = deferred();
+ let includeActionItem = false;
+ const fetchMock = vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith(`/api/emails/${EMAIL.id}`)) return Promise.resolve(jsonResponse(EMAIL));
+ if (url.endsWith("/api/llm/summarize")) {
+ return Promise.resolve(jsonResponse({
+ summary: "Summary",
+ action_items: includeActionItem ? ["Review the meeting"] : [],
+ }));
+ }
+ if (url.endsWith("/api/calendar/writeback-sources")) return Promise.resolve(jsonResponse(SOURCES));
+ if (url.endsWith("/api/calendar/writeback-intent")) return writeback.promise;
+ return Promise.resolve(jsonResponse({}));
+ });
+
+ let rendered = await renderWithFetch(fetchMock);
+ selectCalendarSource(rendered, SOURCES[0].source_id);
+ expect(findButton(rendered, "일정 조율")?.disabled).toBe(true);
+
+ act(() => root?.unmount());
+ root = null;
+ rendered.remove();
+ container = null;
+ includeActionItem = true;
+ rendered = await renderWithFetch(fetchMock);
+ selectCalendarSource(rendered, SOURCES[0].source_id);
+ const scheduleButton = findButton(rendered, "일정 조율");
+ expect(scheduleButton?.disabled).toBe(false);
+
+ await act(async () => {
+ scheduleButton?.click();
+ await Promise.resolve();
+ });
+ expect(scheduleButton?.disabled).toBe(true);
+ expect(scheduleButton?.textContent).toContain("조율 중");
+
+ await act(async () => {
+ writeback.resolve(jsonResponse(intentResponse(SOURCES[0].source_id)));
+ await writeback.promise;
+ });
+ await flushAsyncWork();
+ });
+
+ it("clears the confirmed source and requires reconfirmation after a source conflict", async () => {
+ const fetchMock = vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith(`/api/emails/${EMAIL.id}`)) return Promise.resolve(jsonResponse(EMAIL));
+ if (url.endsWith("/api/llm/summarize")) {
+ return Promise.resolve(jsonResponse({ summary: "Summary", action_items: ["Review the meeting"] }));
+ }
+ if (url.endsWith("/api/calendar/writeback-sources")) return Promise.resolve(jsonResponse(SOURCES));
+ if (url.endsWith("/api/calendar/writeback-intent")) {
+ return Promise.resolve(jsonResponse({ detail: "source conflict" }, 409));
+ }
+ return Promise.resolve(jsonResponse({}));
+ });
+
+ const rendered = await renderWithFetch(fetchMock);
+ const sourceSelect = selectCalendarSource(rendered, SOURCES[0].source_id);
+ const scheduleButton = findButton(rendered, "일정 조율");
+ await act(async () => {
+ scheduleButton?.click();
+ await flushAsyncWork();
+ });
+
+ expect(sourceSelect?.value).toBe("");
+ expect(scheduleButton?.disabled).toBe(true);
+ expect(rendered.textContent).toContain("원본 일정이 변경되었습니다. 일정 원본을 다시 선택해 주세요.");
+ });
+
+ it("reports partial intent failures without inventing a provider event identifier", async () => {
+ const actionItems = ["Review the meeting", "Send the agenda"];
+ const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith(`/api/emails/${EMAIL.id}`)) return Promise.resolve(jsonResponse(EMAIL));
+ if (url.endsWith("/api/llm/summarize")) {
+ return Promise.resolve(jsonResponse({ summary: "Summary", action_items: actionItems }));
+ }
+ if (url.endsWith("/api/calendar/writeback-sources")) return Promise.resolve(jsonResponse(SOURCES));
+ if (url.endsWith("/api/calendar/writeback-intent")) {
+ const payload = JSON.parse(String(init?.body)) as { summary?: string };
+ return payload.summary === actionItems[0]
+ ? Promise.resolve(jsonResponse(intentResponse(SOURCES[0].source_id)))
+ : Promise.resolve(jsonResponse({ detail: "failed" }, 500));
+ }
+ return Promise.resolve(jsonResponse({}));
+ });
+
+ const rendered = await renderWithFetch(fetchMock);
+ selectCalendarSource(rendered, SOURCES[0].source_id);
+ const scheduleButton = findButton(rendered, "일정 조율");
+ await act(async () => {
+ scheduleButton?.click();
+ await flushAsyncWork();
+ });
+
+ expect(rendered.textContent).toContain("1개 성공, 1개 실패");
+ const calendarEvent = getRecordedProductEvents().find((event) => event.name === "calendar_reflected");
+ expect(calendarEvent?.payload.calendar_event_id).toBeNull();
+ expect(calendarEvent?.payload.conflict_state).toBe("warning");
+ expect(calendarEvent?.payload.provider_write_executed).toBe(false);
+ });
+});
diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx
index db2b617b6..126aa33f5 100644
--- a/frontend/src/components/EmailDetail.test.tsx
+++ b/frontend/src/components/EmailDetail.test.tsx
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/ui/separator", () => ({
Separator: () => ,
@@ -73,6 +73,7 @@ type TestEmail = {
subject: string;
date: string;
body: string;
+ schedule_conflict?: boolean;
};
function deferred(): Deferred {
@@ -110,6 +111,10 @@ describe("EmailDetail", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
+ beforeEach(() => {
+ vi.spyOn(console, "error");
+ });
+
afterEach(() => {
if (root) {
act(() => root?.unmount());
@@ -119,6 +124,12 @@ describe("EmailDetail", () => {
container = null;
vi.unstubAllGlobals();
clearRecordedProductEvents();
+ const errorSpy = vi.mocked(console.error);
+ const actWarnings = errorSpy.mock.calls.filter((argumentsList) =>
+ argumentsList.some((argument) => String(argument).includes("not wrapped in act")),
+ );
+ errorSpy.mockRestore();
+ expect(actWarnings).toEqual([]);
});
it("translates email content when the Translate button is clicked", async () => {
@@ -850,75 +861,237 @@ describe("EmailDetail", () => {
expect(container.textContent).toContain("캘린더에 반영할 실행 항목이 없습니다.");
});
- it("waits for context synthesis action items before requesting a server-authoritative calendar writeback intent", async () => {
- const email: TestEmail = {
- id: 9,
- message_id: "",
- thread_id: null,
- sender: "calendar@example.com",
- recipients: "user@example.com",
- subject: "Calendar command",
- date: "2026-05-17T10:00:00Z",
- body: "Please sync the launch meeting.",
- };
- const summaryResponse = deferred>();
- const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
- const url = String(input);
- if (url.endsWith("/api/emails/9")) return Promise.resolve(jsonResponse(email));
- if (url.endsWith("/api/llm/summarize")) return summaryResponse.promise;
- if (url.endsWith("/api/calendar/writeback-intent")) {
- expect(init?.method).toBe("POST");
- expect(JSON.parse(String(init?.body))).toEqual({ action: "create", summary: "출시 회의 일정 잡기" });
- return Promise.resolve(jsonResponse({
- workspace_id: "default",
- target_source_id: "caldav-primary",
- protocol: "caldav",
- writeback_mode: "customer_owned",
- requires_if_match: false,
- if_match: null,
- provenance: {
- created_by: "default",
- source_provider: "Customer CalDAV",
- source_protocol: "caldav",
- },
- audit_event: "calendar.writeback_intent.created",
- }));
- }
- throw new Error(`Unexpected fetch: ${url}`);
- });
- vi.stubGlobal("fetch", fetchMock);
+it("waits for context synthesis and explicit source confirmation before requesting a server-authoritative calendar writeback intent", async () => {
+ const email: TestEmail = {
+ id: 9,
+ message_id: "",
+ thread_id: null,
+ sender: "calendar@example.com",
+ recipients: "user@example.com",
+ subject: "Calendar command",
+ date: "2026-05-17T10:00:00Z",
+ body: "Please sync the launch meeting.",
+ };
+ const summaryResponse = deferred>();
+
+ const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith("/api/emails/9")) return Promise.resolve(jsonResponse(email));
+ if (url.endsWith("/api/llm/summarize")) return summaryResponse.promise;
+ if (url.endsWith("/api/calendar/writeback-sources")) {
+ return Promise.resolve(jsonResponse([{
+ source_id: "caldav_source_primary",
+ provider: "Fastmail",
+ protocol: "caldav",
+ owner_id: "owner_primary",
+ organization_id: null,
+ capabilities: ["read", "write", "etag"],
+ writeback_enabled: true,
+ etag: "etag-primary",
+ }]));
+ }
+ if (url.endsWith("/api/calendar/writeback-intent")) {
+ expect(init?.method).toBe("POST");
+ expect(JSON.parse(String(init?.body))).toEqual({
+ action: "create",
+ summary: "출시 회의 일정 잡기",
+ target_source_id: "caldav_source_primary",
+ });
+ return Promise.resolve(jsonResponse({
+ workspace_id: "default",
+ target_source_id: "caldav_source_primary",
+ protocol: "caldav",
+ writeback_mode: "customer_owned",
+ requires_if_match: true,
+ if_match: "etag-primary",
+ provenance: {
+ created_by: "default",
+ source_provider: "Fastmail",
+ source_protocol: "caldav",
+ },
+ audit_event: "calendar.writeback_intent.created",
+ provider_write_executed: false,
+ }));
+ }
+ throw new Error(`Unexpected fetch: ${url}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
- await act(async () => {
- root?.render( );
- });
- await flushAsyncWork();
+ await act(async () => {
+ root?.render( );
+ });
+ await flushAsyncWork();
- expect(fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/calendar/writeback-intent"))).toHaveLength(0);
- expect(fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/calendar/sync"))).toHaveLength(0);
+ expect(fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/calendar/writeback-intent"))).toHaveLength(0);
+ expect(fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/calendar/sync"))).toHaveLength(0);
- await act(async () => {
- summaryResponse.resolve(jsonResponse({ summary: "회의 일정", action_items: ["출시 회의 일정 잡기"] }));
- await summaryResponse.promise;
- });
- await flushAsyncWork();
+ await act(async () => {
+ summaryResponse.resolve(jsonResponse({ summary: "회의 일정", action_items: ["출시 회의 일정 잡기"] }));
+ await summaryResponse.promise;
+ });
+ await waitForCondition(() =>
+ container?.querySelector('select[aria-label="일정 원본 선택"]') !== null,
+ );
+
+ expect(fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/calendar/writeback-intent"))).toHaveLength(0);
+ const sourceSelect = container.querySelector(
+ 'select[aria-label="일정 원본 선택"]',
+ );
+ expect(sourceSelect).not.toBeNull();
+ act(() => {
+ if (!sourceSelect) return;
+ sourceSelect.value = "caldav_source_primary";
+ sourceSelect.dispatchEvent(new Event("change", { bubbles: true }));
+ });
- expect(fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/calendar/writeback-intent"))).toHaveLength(1);
- expect(fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/calendar/sync"))).toHaveLength(0);
- expect(container.textContent).toContain("1개 일정 반영 의도를 선택한 원본 계정에 요청했습니다.");
- expect(container.textContent).not.toContain("Customer CalDAV");
- expect(container.textContent).not.toContain("caldav-primary");
- expect(container.textContent).not.toContain("calendar.writeback_intent.created");
- expect(getRecordedProductEvents().some((event) =>
- event.name === "calendar_reflected" &&
- event.payload.calendar_candidate_id === "mail-calendar:9" &&
- event.payload.provider_write_executed === false,
- )).toBe(true);
+ const scheduleButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("일정 반영"),
+ );
+ expect(scheduleButton?.disabled).toBe(false);
+ act(() => scheduleButton?.click());
+ await flushAsyncWork();
+
+ expect(fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/calendar/writeback-intent"))).toHaveLength(1);
+ expect(fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/calendar/sync"))).toHaveLength(0);
+ expect(container.textContent).toContain("1개 일정 반영 의도를 Fastmail 원본에 요청했습니다.");
+ expect(container.textContent).not.toContain("caldav_source_primary");
+ expect(container.textContent).not.toContain("calendar.writeback_intent.created");
+ expect(getRecordedProductEvents().some((event) =>
+ event.name === "calendar_reflected" &&
+ event.payload.calendar_candidate_id === "mail-calendar:9" &&
+ event.payload.calendar_event_id === null &&
+ event.payload.provider_write_executed === false,
+ )).toBe(true);
+});
+
+it("disables calendar coordination when synthesis has no action items", async () => {
+ const email: TestEmail = {
+ id: 20,
+ message_id: "",
+ thread_id: null,
+ sender: "calendar@example.com",
+ recipients: "user@example.com",
+ subject: "Calendar without an action",
+ date: "2026-05-17T11:00:00Z",
+ body: "No calendar action is required.",
+ schedule_conflict: true,
+ };
+ const fetchMock = vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/emails/20")) return Promise.resolve(jsonResponse(email));
+ if (url.endsWith("/api/llm/summarize")) {
+ return Promise.resolve(jsonResponse({ summary: "확인 완료", action_items: [] }));
+ }
+ if (url.endsWith("/api/calendar/writeback-sources")) {
+ return Promise.resolve(jsonResponse([]));
+ }
+ throw new Error(`Unexpected fetch: ${url}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render( );
+ });
+ await flushAsyncWork();
+
+ const scheduleButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("일정 조율"),
+ );
+ expect(scheduleButton?.disabled).toBe(true);
+ expect(fetchMock.mock.calls.filter(([input]) => String(input).endsWith("/api/calendar/writeback-intent"))).toHaveLength(0);
+});
+
+it("disables calendar coordination while the writeback request is pending", async () => {
+ const email: TestEmail = {
+ id: 21,
+ message_id: "",
+ thread_id: null,
+ sender: "calendar@example.com",
+ recipients: "user@example.com",
+ subject: "Calendar pending",
+ date: "2026-05-17T12:00:00Z",
+ body: "Please sync the launch meeting.",
+ schedule_conflict: true,
+ };
+ const writebackResponse = deferred>();
+ const fetchMock = vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/emails/21")) return Promise.resolve(jsonResponse(email));
+ if (url.endsWith("/api/llm/summarize")) {
+ return Promise.resolve(jsonResponse({ summary: "회의 일정", action_items: ["출시 회의 일정 잡기"] }));
+ }
+ if (url.endsWith("/api/calendar/writeback-sources")) {
+ return Promise.resolve(jsonResponse([{
+ source_id: "caldav_source_pending",
+ provider: "Fastmail",
+ protocol: "caldav",
+ owner_id: "owner_pending",
+ organization_id: null,
+ capabilities: ["read", "write", "etag"],
+ writeback_enabled: true,
+ etag: "etag-pending",
+ }]));
+ }
+ if (url.endsWith("/api/calendar/writeback-intent")) return writebackResponse.promise;
+ throw new Error(`Unexpected fetch: ${url}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render( );
+ });
+ await flushAsyncWork();
+ await waitForCondition(() => container?.querySelector('select[aria-label="일정 원본 선택"]') !== null);
+
+ const sourceSelect = container.querySelector('select[aria-label="일정 원본 선택"]');
+ expect(sourceSelect).not.toBeNull();
+ act(() => {
+ if (!sourceSelect) return;
+ sourceSelect.value = "caldav_source_pending";
+ sourceSelect.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+
+ const scheduleButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("일정 조율"),
+ );
+ expect(scheduleButton?.disabled).toBe(false);
+ await act(async () => {
+ scheduleButton?.click();
+ await Promise.resolve();
+ });
+ expect(scheduleButton?.disabled).toBe(true);
+ expect(scheduleButton?.textContent).toContain("조율 중");
+
+ await act(async () => {
+ writebackResponse.resolve(jsonResponse({
+ workspace_id: "default",
+ target_source_id: "caldav_source_pending",
+ protocol: "caldav",
+ writeback_mode: "customer_owned",
+ requires_if_match: true,
+ if_match: "etag-pending",
+ provenance: { created_by: "default", source_provider: "Fastmail", source_protocol: "caldav" },
+ audit_event: "calendar.writeback_intent.created",
+ provider_write_executed: false,
+ }));
+ await writebackResponse.promise;
+ await flushAsyncWork();
});
+ expect(container.textContent).toContain("1개 일정 반영 의도를 Fastmail 원본에 요청했습니다.");
+});
it("ignores a late draft response after the selected email changes", async () => {
const emailA: TestEmail = {
@@ -1010,8 +1183,10 @@ describe("EmailDetail", () => {
const textInput = container.querySelector('input[placeholder="예: 정중하게 답장해줘"]');
if (textInput) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
- setter?.call(textInput, "test");
- textInput.dispatchEvent(new Event("input", { bubbles: true }));
+ await act(async () => {
+ setter?.call(textInput, "test");
+ textInput.dispatchEvent(new Event("input", { bubbles: true }));
+ });
}
const draftButton = Array.from(container.querySelectorAll("button")).find(
@@ -1208,8 +1383,10 @@ describe("EmailDetail", () => {
const textInput = container.querySelector('#reply-draft');
if (textInput) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
- setter?.call(textInput, "test draft");
- textInput.dispatchEvent(new Event("input", { bubbles: true }));
+ await act(async () => {
+ setter?.call(textInput, "test draft");
+ textInput.dispatchEvent(new Event("input", { bubbles: true }));
+ });
}
const clearButton = Array.from(container.querySelectorAll("button")).find(
@@ -1258,8 +1435,10 @@ describe("EmailDetail", () => {
const draftInput = container.querySelector('#reply-draft');
if (draftInput) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
- setter?.call(draftInput, "This is my draft to send.");
- draftInput.dispatchEvent(new Event("input", { bubbles: true }));
+ await act(async () => {
+ setter?.call(draftInput, "This is my draft to send.");
+ draftInput.dispatchEvent(new Event("input", { bubbles: true }));
+ });
}
const sendButton = Array.from(container.querySelectorAll("button")).find(
@@ -1312,8 +1491,10 @@ describe("EmailDetail", () => {
const draftInput = container.querySelector('#reply-draft');
if (draftInput) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
- setter?.call(draftInput, "This is my draft to send and fail.");
- draftInput.dispatchEvent(new Event("input", { bubbles: true }));
+ await act(async () => {
+ setter?.call(draftInput, "This is my draft to send and fail.");
+ draftInput.dispatchEvent(new Event("input", { bubbles: true }));
+ });
}
const sendButton = Array.from(container.querySelectorAll("button")).find(
@@ -1326,4 +1507,98 @@ describe("EmailDetail", () => {
expect(container.textContent).toContain("답장 전송에 실패했습니다.");
});
+
+ it("renders responsive participant and attachment evidence and executes the meeting action", async () => {
+ const email = {
+ id: 30,
+ message_id: "",
+ thread_id: null,
+ sender: "sender@example.com",
+ recipients: "user@example.com",
+ subject: "UI Density",
+ date: "2026-05-18T10:00:00Z",
+ body: "High density UI",
+ schedule_conflict: true,
+ requires_reply: true,
+ attachments: ["proposal.pdf", "schedule.xlsx"],
+ };
+ const actionItem = "Review project meeting on 2026-05-19";
+ const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith("/api/emails/30")) return Promise.resolve(jsonResponse(email));
+ if (url.endsWith("/api/llm/summarize")) {
+ return Promise.resolve(jsonResponse({ summary: "Summary", action_items: [actionItem] }));
+ }
+ if (url.endsWith("/api/calendar/writeback-sources")) {
+ return Promise.resolve(jsonResponse([{
+ source_id: "caldav_source_primary",
+ provider: "Fastmail",
+ protocol: "caldav",
+ owner_id: "owner_primary",
+ organization_id: null,
+ capabilities: ["read", "write", "etag"],
+ writeback_enabled: true,
+ etag: "etag-primary",
+ }]));
+ }
+ if (url.endsWith("/api/calendar/writeback-intent") && init?.method === "POST") {
+ return Promise.resolve(jsonResponse({
+ target_source_id: "caldav_source_primary",
+ protocol: "caldav",
+ provider_write_executed: false,
+ provenance: { source_provider: "Fastmail" },
+ }));
+ }
+ throw new Error(`Unexpected fetch: ${url}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ await act(async () => { root?.render( ); });
+ await act(async () => { await flushAsyncWork(); });
+
+ expect(container.textContent).toContain("sender@example.com, user@example.com");
+ expect(container.textContent).toContain("참여자");
+ expect(container.textContent).toContain("proposal.pdf");
+ expect(container.textContent).toContain("회의 제안 확인");
+
+ const attachmentRail = container.querySelector('[role="region"][aria-label="첨부파일"]');
+ expect(attachmentRail).not.toBeNull();
+ expect(attachmentRail?.classList.contains("hidden")).toBe(false);
+ expect(attachmentRail?.classList.contains("overflow-x-auto")).toBe(true);
+
+ const sourceSelect = container.querySelector(
+ 'select[aria-label="일정 원본 선택"]',
+ );
+ expect(sourceSelect).not.toBeNull();
+ act(() => {
+ if (!sourceSelect) return;
+ sourceSelect.value = "caldav_source_primary";
+ sourceSelect.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+
+ const scheduleButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("일정 조율"),
+ );
+ expect(scheduleButton?.disabled).toBe(false);
+ await act(async () => {
+ scheduleButton?.click();
+ await flushAsyncWork();
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ "/api/calendar/writeback-intent",
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({
+ action: "create",
+ summary: actionItem,
+ target_source_id: "caldav_source_primary",
+ }),
+ }),
+ );
+ expect(container.textContent).toContain("1개 일정 반영 의도를 Fastmail 원본에 요청했습니다.");
+ });
});
diff --git a/frontend/src/components/EmailDetail.tsx b/frontend/src/components/EmailDetail.tsx
index e634a896c..81456d2ca 100644
--- a/frontend/src/components/EmailDetail.tsx
+++ b/frontend/src/components/EmailDetail.tsx
@@ -25,10 +25,20 @@ import {
createProductEventId,
recordProductEvent,
} from "@/lib/product-events";
+import type {
+ CalendarWritebackIntentResponse,
+ CalendarWritebackSource,
+} from "@/components/calendar/types";
+import {
+ getApiErrorStatus,
+ isCustomerOwnedWritableSource,
+} from "@/components/calendar/helpers";
type EmailData = ThreadEmailData & {
requires_reply?: boolean;
schedule_conflict?: boolean;
+ recipients?: string;
+ attachments?: string[];
};
interface LlmData {
summary: string;
@@ -49,19 +59,6 @@ interface CreateTasksFromEmailResponse {
created: number;
}
-interface CalendarWritebackIntentResponse {
- target_source_id: string;
- protocol: string;
- provider_write_executed?: boolean;
- status?: string;
- runner_request_id?: string | null;
- provider_status?: number | null;
- error_code?: string | null;
- provenance: {
- source_provider?: string;
- };
-}
-
type EmailDetailActionCommand = {
id: number;
action: string;
@@ -83,6 +80,11 @@ function toStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
+function getCalendarWritebackSourceLabel(source: CalendarWritebackSource): string {
+ const provider = source.provider.trim();
+ return `${provider || source.protocol.toUpperCase()} 원본`;
+}
+
function normalizeLlmData(payload: unknown): LlmData {
if (!payload || typeof payload !== "object") {
throw new Error("Invalid LLM summary response");
@@ -129,6 +131,46 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand =
const [isSyncing, setIsSyncing] = useState(false);
const [isCreatingTask, setIsCreatingTask] = useState(false);
const [syncStatus, setSyncStatus] = useState<{type: 'success' | 'error', message: string} | null>(null);
+ type CalendarSourceLoadStatus = 'idle' | 'loading' | 'ready' | 'error';
+ type CalendarSourceState = {
+ contextKey: string;
+ sources: CalendarWritebackSource[];
+ selectedSourceId: string;
+ status: CalendarSourceLoadStatus;
+ };
+ const actionItemCount = llmData?.action_items.length ?? 0;
+ const sourceContextIsActionable = email?.id === emailId && actionItemCount > 0;
+ const sourceContextKey = `${emailId ?? 'none'}:${email?.id ?? 'none'}:${sourceContextIsActionable ? 'actionable' : 'idle'}`;
+ const [loadedCalendarSourceState, setLoadedCalendarSourceState] = useState(() => ({
+ contextKey: sourceContextKey,
+ sources: [],
+ selectedSourceId: '',
+ status: sourceContextIsActionable ? 'loading' : 'idle',
+ }));
+ const calendarSourceState: CalendarSourceState = loadedCalendarSourceState.contextKey === sourceContextKey
+ ? loadedCalendarSourceState
+ : {
+ contextKey: sourceContextKey,
+ sources: [],
+ selectedSourceId: '',
+ status: sourceContextIsActionable ? 'loading' : 'idle',
+ };
+ const writebackSources = calendarSourceState.sources;
+ const selectedWritebackSourceId = calendarSourceState.selectedSourceId;
+ const sourceLoadStatus = calendarSourceState.status;
+ const setSelectedWritebackSourceId = useCallback((selectedSourceId: string) => {
+ setLoadedCalendarSourceState((current) => {
+ const activeState = current.contextKey === sourceContextKey
+ ? current
+ : {
+ contextKey: sourceContextKey,
+ sources: [],
+ selectedSourceId: '',
+ status: sourceContextIsActionable ? 'loading' as const : 'idle' as const,
+ };
+ return { ...activeState, selectedSourceId };
+ });
+ }, [sourceContextIsActionable, sourceContextKey]);
const [taskStatus, setTaskStatus] = useState(null);
const [sourceDrawerOpen, setSourceDrawerOpen] = useState(false);
const threadRequestIdRef = useRef(0);
@@ -145,6 +187,39 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand =
handledActionCommandIdRef.current = null;
}, [emailId]);
+ useEffect(() => {
+ if (!sourceContextIsActionable) return;
+
+ let isMounted = true;
+ const requestedContextKey = sourceContextKey;
+ void apiClient.get('/api/calendar/writeback-sources')
+ .then((sources) => {
+ if (!isMounted) return;
+ if (!Array.isArray(sources)) {
+ throw new Error('Invalid calendar source registry response');
+ }
+ setLoadedCalendarSourceState({
+ contextKey: requestedContextKey,
+ sources: sources.filter(isCustomerOwnedWritableSource),
+ selectedSourceId: '',
+ status: 'ready',
+ });
+ })
+ .catch(() => {
+ if (!isMounted) return;
+ setLoadedCalendarSourceState({
+ contextKey: requestedContextKey,
+ sources: [],
+ selectedSourceId: '',
+ status: 'error',
+ });
+ });
+
+ return () => {
+ isMounted = false;
+ };
+ }, [sourceContextIsActionable, sourceContextKey]);
+
const fetchThread = useCallback(async (currentEmail: EmailData) => {
const requestId = threadRequestIdRef.current + 1;
threadRequestIdRef.current = requestId;
@@ -200,6 +275,12 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand =
setIsSyncing(false);
setIsCreatingTask(false);
setSyncStatus(null);
+ setLoadedCalendarSourceState({
+ contextKey: '',
+ sources: [],
+ selectedSourceId: '',
+ status: 'idle',
+ });
setTaskStatus(null);
setSourceDrawerOpen(false);
activeDraftReplyIdRef.current = null;
@@ -421,53 +502,95 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand =
const actionEmailId = emailId;
const isCurrentEmail = () => currentEmailIdRef.current === actionEmailId;
const actionItems = llmData?.action_items ?? [];
+ const selectedSource = writebackSources.find(
+ (candidate) => candidate.source_id === selectedWritebackSourceId
+ && isCustomerOwnedWritableSource(candidate),
+ ) ?? null;
+
if (!actionItems.length) {
setSyncStatus({ type: 'error', message: '캘린더에 반영할 실행 항목이 없습니다.' });
return;
}
+ if (sourceLoadStatus !== 'ready' || selectedSource === null) {
+ setSyncStatus({ type: 'error', message: '일정 원본을 먼저 선택해 주세요.' });
+ return;
+ }
+
setIsSyncing(true);
setSyncStatus(null);
const startedAt = nowMs();
+ const settledIntents = await Promise.allSettled(
+ actionItems.map((summary) =>
+ apiClient.post('/api/calendar/writeback-intent', {
+ action: 'create',
+ summary,
+ target_source_id: selectedSource.source_id,
+ }),
+ ),
+ );
+
try {
- const intents = await Promise.all(
- actionItems.map((summary) =>
- apiClient.post('/api/calendar/writeback-intent', {
- action: 'create',
- summary,
- }),
- ),
- );
if (!isCurrentEmail()) return;
- setSyncStatus({ type: 'success', message: `${intents.length}개 일정 반영 의도를 선택한 원본 계정에 요청했습니다.` });
+ const successfulIntents = settledIntents.flatMap((result) => {
+ if (result.status !== 'fulfilled') return [];
+ return result.value.target_source_id === selectedSource.source_id
+ ? [result.value]
+ : [];
+ });
+ const failedCount = settledIntents.length - successfulIntents.length;
+ const conflictDetected = settledIntents.some(
+ (result) => result.status === 'rejected' && getApiErrorStatus(result.reason) === 409,
+ );
+
+ if (conflictDetected) {
+ setSelectedWritebackSourceId('');
+ setSyncStatus({
+ type: 'error',
+ message: '원본 일정이 변경되었습니다. 일정 원본을 다시 선택해 주세요.',
+ });
+ } else if (failedCount === 0) {
+ setSyncStatus({
+ type: 'success',
+ message: `${successfulIntents.length}개 일정 반영 의도를 ${getCalendarWritebackSourceLabel(selectedSource)}에 요청했습니다.`,
+ });
+ } else if (successfulIntents.length > 0) {
+ setSyncStatus({
+ type: 'error',
+ message: `${successfulIntents.length}개 성공, ${failedCount}개 실패했습니다. 선택한 원본에는 성공한 의도만 기록했습니다.`,
+ });
+ } else {
+ setSyncStatus({ type: 'error', message: '일정 반영 의도 요청에 실패했습니다.' });
+ }
+
recordProductEvent("calendar_reflected", {
surface: "mail_detail",
calendar_candidate_id: `mail-calendar:${actionEmailId ?? "unknown"}`,
- calendar_event_id: intents[0]?.target_source_id ?? null,
+ calendar_event_id: null,
thread_id: email ? getThreadEventId(email) : null,
- conflict_state: "none",
- provider_write_executed: intents.some((intent) => Boolean(intent.provider_write_executed)),
- });
- recordProductEvent("latency_guardrail_recorded", {
- surface: "mail_detail",
- request_trace_id: createProductEventId("calendar_trace"),
- operation: "calendar_reflection",
- duration_ms: Math.round(nowMs() - startedAt),
- status: "success",
+ conflict_state: conflictDetected ? "conflict" : failedCount > 0 ? "warning" : "none",
+ provider_write_executed: successfulIntents.some(
+ (intent) => Boolean(intent.provider_write_executed),
+ ),
});
- } catch {
- if (!isCurrentEmail()) return;
- setSyncStatus({ type: 'error', message: '일정 반영 의도 요청에 실패했습니다.' });
recordProductEvent("latency_guardrail_recorded", {
surface: "mail_detail",
request_trace_id: createProductEventId("calendar_trace"),
operation: "calendar_reflection",
duration_ms: Math.round(nowMs() - startedAt),
- status: "error",
+ status: failedCount === 0 ? "success" : "error",
});
} finally {
if (isCurrentEmail()) setIsSyncing(false);
}
- }, [email, emailId, llmData]);
+ }, [
+ email,
+ emailId,
+ llmData,
+ selectedWritebackSourceId,
+ setSelectedWritebackSourceId,
+ sourceLoadStatus,
+ writebackSources,
+ ]);
const handleCreateTask = useCallback(async () => {
const actionEmail = email;
@@ -570,8 +693,62 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand =
const safeEmailSender = toMailDisplayText(email.sender, '보낸 사람');
const safeEmailSubject = toMailDisplayText(email.subject, '(제목 없음)');
const safeReplyTo = toMailDisplayText(email.reply_to || email.sender, '답장 주소 없음');
+ const safeRecipients = toMailDisplayText(email.recipients || email.sender, '참여자 없음');
+ const safeParticipants = Array.from(new Set([safeEmailSender, safeRecipients])).join(', ');
const confidencePercent = toConfidencePercent(llmData?.confidence);
const actionItems = llmData?.action_items ?? [];
+ const selectedWritebackSource = writebackSources.find(
+ (candidate) => candidate.source_id === selectedWritebackSourceId
+ && isCustomerOwnedWritableSource(candidate),
+ ) ?? null;
+ const isCalendarWritebackDisabled = isSyncing
+ || actionItems.length === 0
+ || sourceLoadStatus !== 'ready'
+ || selectedWritebackSource === null;
+ const calendarSourceSelectId = `email-calendar-source-${email.id}`;
+ const calendarSourceSelector = (
+
+
+ 일정 원본
+
+
{
+ setSelectedWritebackSourceId(event.target.value);
+ setSyncStatus(null);
+ }}
+ disabled={isSyncing || sourceLoadStatus !== 'ready' || writebackSources.length === 0}
+ className="mt-2 h-9 w-full rounded-lg border border-emerald-500/30 bg-card px-3 text-xs font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-60"
+ >
+ 일정 원본을 선택하세요
+ {writebackSources.map((sourceOption) => (
+
+ {getCalendarWritebackSourceLabel(sourceOption)} · {sourceOption.protocol.toUpperCase()}
+
+ ))}
+
+
+ {sourceLoadStatus === 'loading'
+ ? '서명 세션으로 일정 원본을 확인하는 중입니다.'
+ : sourceLoadStatus === 'error'
+ ? '일정 원본을 확인할 수 없어 외부 반영을 차단했습니다.'
+ : writebackSources.length === 0
+ ? '반영 가능한 고객 원본 일정이 없습니다.'
+ : selectedWritebackSource
+ ? `${getCalendarWritebackSourceLabel(selectedWritebackSource)}을(를) 명시적으로 선택했습니다.`
+ : '반영 전에 고객 원본 일정을 명시적으로 선택해야 합니다.'}
+
+
+ );
const handleOpenSourceDrawer = () => {
recordProductEvent("source_chip_opened", {
@@ -631,9 +808,31 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand =
{safeEmailSender}
-
+
답장 주소: {safeReplyTo}
+
+ 참여자:
+ {safeParticipants}
+
+ {email.attachments && email.attachments.length > 0 && (
+
+ 첨부파일:
+ {email.attachments.map((file, idx) => (
+
+ {toMailDisplayText(file, '첨부파일')}
+
+ ))}
+
+ )}
@@ -652,6 +851,27 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand =
+ {email.schedule_conflict && (
+
+
+
+
회의 제안 확인
+
이메일에 포함된 회의 일정을 캘린더와 조율합니다.
+
+
+ {isSyncing && }
+ {isSyncing ? "조율 중" : "일정 조율"}
+
+
+
{calendarSourceSelector}
+
+ )}
✦}
@@ -693,7 +913,7 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand =
@@ -715,7 +935,11 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand =
)}
{syncStatus && (
-
+
{syncStatus.message}
)}
@@ -727,6 +951,9 @@ export const EmailDetail = memo(function EmailDetail({ emailId, actionCommand =
>
) : null}
>
+ {!email.schedule_conflict && actionItems.length > 0 ? (
+ {calendarSourceSelector}
+ ) : null}
{llmData ? (
actionItems.length > 0 ? (