Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`으로 복구해 다시 해석·설치 가능하게 했습니다.
Expand Down
49 changes: 49 additions & 0 deletions docs/doctoring/email-detail-responsive-action-surface.md
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions frontend/src/components/EmailDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<ui-density@example.com>",
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(<EmailDetail emailId={30} />); });
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<HTMLElement>('[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<HTMLButtonElement>("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개 일정 반영 의도를 선택한 원본 계정에 요청했습니다.");
});
});
53 changes: 51 additions & 2 deletions frontend/src/components/EmailDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
type EmailData = ThreadEmailData & {
requires_reply?: boolean;
schedule_conflict?: boolean;
recipients?: string;
attachments?: string[];
};
interface LlmData {
summary: string;
Expand Down Expand Up @@ -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 ?? [];

Expand Down Expand Up @@ -628,9 +632,30 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
<div className="line-clamp-1 text-xs">
<span className="text-muted-foreground">{safeEmailSender}</span>
</div>
<div className="line-clamp-1 text-xs text-muted-foreground">
<div className="line-clamp-1 text-xs text-muted-foreground sm:hidden">
답장 주소: {safeReplyTo}
</div>
<div className="text-xs text-muted-foreground">
<span className="mr-1 font-semibold">참여자:</span>
<span className="break-all">{safeParticipants}</span>
</div>
{email.attachments && email.attachments.length > 0 && (
<div
aria-label="첨부파일"
className="mt-2 flex min-w-0 items-center gap-2 overflow-x-auto pb-1"
>
<span className="shrink-0 text-xs font-semibold text-muted-foreground">첨부파일:</span>
{email.attachments.map((file, idx) => (
<Badge
key={`${file}-${idx}`}
variant="secondary"
className="h-5 shrink-0 px-2 py-0 text-[10px]"
>
{toMailDisplayText(file, '첨부파일')}
</Badge>
))}
</div>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="hidden whitespace-nowrap rounded-full border border-border bg-card px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm 2xl:block">
Expand All @@ -649,6 +674,26 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
<ScrollArea className="flex-1">
<div className="flex flex-col gap-6 bg-background/50 p-6 pb-[calc(7rem+env(safe-area-inset-bottom))] lg:pb-6">

{email.schedule_conflict && (
<div className="rounded-2xl border border-emerald-500/30 bg-emerald-500/10 p-4 shadow-sm">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-bold text-emerald-800 dark:text-emerald-200">회의 제안 확인</h3>
<p className="mt-1 text-xs text-emerald-700/80 dark:text-emerald-300/80">이메일에 포함된 회의 일정을 캘린더와 조율합니다.</p>
</div>
<Button
size="sm"
onClick={handleSyncCalendar}
disabled={isSyncing || actionItems.length === 0}
aria-busy={isSyncing}
className="h-8 rounded-xl bg-emerald-600 px-3 text-xs text-white hover:bg-emerald-700"
>
{isSyncing && <Loader2 className="mr-2 h-3 w-3 animate-spin" aria-hidden="true" />}
{isSyncing ? "조율 중" : "일정 조율"}
</Button>
</div>
</div>
)}
<DecisionPointCard
title="맥락 종합"
icon={<span aria-hidden="true">✦</span>}
Expand Down Expand Up @@ -712,7 +757,11 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
</Button>
)}
{syncStatus && (
<span className={`self-center text-xs ${syncStatus.type === 'success' ? 'text-green-600' : 'text-red-500'}`}>
<span
role="status"
aria-live="polite"
className={`self-center text-xs ${syncStatus.type === 'success' ? 'text-green-600' : 'text-red-500'}`}
>
{syncStatus.message}
</span>
)}
Expand Down