diff --git a/CHANGELOG.md b/CHANGELOG.md index a06003d8f..231d21c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ ## [Unreleased] +### EmailDetail 반응형 실행 표면 + +- 참여자와 첨부파일 증거를 모바일·데스크톱에서 동일하게 확인할 수 있도록 반응형 스크롤 레일과 명시적 접근성 이름을 추가했습니다. +- 일정 충돌 패널의 `일정 조율` 버튼을 기존 calendar writeback intent에 연결하고 loading·disabled·live-status 상태를 검증합니다. +- UI PR에 섞인 thread ID, SMTP allowlist, `.msg` import, tenant scope backend 변경은 정확한 `develop` 기준으로 제거했습니다. + ### 보안 패치 (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/docs/doctoring/email-detail-responsive-action-surface.md b/docs/doctoring/email-detail-responsive-action-surface.md new file mode 100644 index 000000000..f71af319e --- /dev/null +++ b/docs/doctoring/email-detail-responsive-action-surface.md @@ -0,0 +1,49 @@ +# 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. Loading, disabled, and polite live-status states remain +in the same product surface. + +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. +- Activating the meeting action sends the exact writeback-intent request. +- Successful writeback intent produces a polite live status. +- 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. + +## References + +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.test.tsx b/frontend/src/components/EmailDetail.test.tsx index a36eeaad5..7152160cc 100644 --- a/frontend/src/components/EmailDetail.test.tsx +++ b/frontend/src/components/EmailDetail.test.tsx @@ -1310,4 +1310,72 @@ 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-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('[aria-label="첨부파일"]'); + expect(attachmentRail).not.toBeNull(); + expect(attachmentRail?.classList.contains("hidden")).toBe(false); + expect(attachmentRail?.classList.contains("overflow-x-auto")).toBe(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 }), + }), + ); + expect(container.textContent).toContain("1개 일정 반영 의도를 선택한 원본 계정에 요청했습니다."); + }); }); diff --git a/frontend/src/components/EmailDetail.tsx b/frontend/src/components/EmailDetail.tsx index 35263d783..eb047cedb 100644 --- a/frontend/src/components/EmailDetail.tsx +++ b/frontend/src/components/EmailDetail.tsx @@ -29,6 +29,8 @@ import { type EmailData = ThreadEmailData & { requires_reply?: boolean; schedule_conflict?: boolean; + recipients?: string; + attachments?: string[]; }; interface LlmData { summary: string; @@ -567,6 +569,8 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number 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 ?? []; @@ -628,9 +632,30 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
{safeEmailSender}
-
+
답장 주소: {safeReplyTo}
+
+ 참여자: + {safeParticipants} +
+ {email.attachments && email.attachments.length > 0 && ( +
+ 첨부파일: + {email.attachments.map((file, idx) => ( + + {toMailDisplayText(file, '첨부파일')} + + ))} +
+ )}
@@ -649,6 +674,26 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
+ {email.schedule_conflict && ( +
+
+
+

회의 제안 확인

+

이메일에 포함된 회의 일정을 캘린더와 조율합니다.

+
+ +
+
+ )}