// frontend/components/mail/MailClient.tsx — 메일 페이지 오케스트레이터 (Topbar 는 layout) "use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "next/navigation"; import useSWR, { useSWRConfig } from "swr"; import { tasksApi } from "@/lib/tasks/api"; import { mailApi } from "@/lib/mail/api"; import { useMail } from "@/lib/hooks/useMail"; import { useDialog } from "@/components/Dialog"; import type { AiReply, EmailDetail, EmailRow, ExtractKind, MailAccount, Person } from "@/lib/types"; import { MailSidebar } from "./MailSidebar"; import { MailList } from "./MailList"; import { Reader } from "./Reader"; import { Triage } from "./Triage"; import { TriageLogModal } from "./TriageLogModal"; import { Compose, type ComposeState } from "./Compose"; // 계정 구분 색 — 계정 tone 이 서로 겹쳐(둘 다 blue) dot 으로 구분이 안 되므로 팔레트에서 배정. const ACC_PALETTE = ["var(--blue)", "var(--violet)", "var(--coral)", "var(--green)", "var(--amber)"]; const FOLDER_META: Record = { smart: "아리 정리", inbox: "받은편지함", others: "기타", starred: "별표", sent: "보낸편지함", drafts: "임시보관함", archive: "보관함", }; type Toast = { id: number; msg: string; tone: string }; function toneColor(tone: string): string { return tone === "coral" ? "var(--coral)" : tone === "violet" ? "var(--violet)" : tone === "green" ? "var(--green)" : "var(--blue)"; } export function MailClient() { // 기본은 통합 받은편지함(전체 받은편지함). 아리 정리(smart)는 사이드바에서 선택. // folder 는 폴더 슬러그(inbox/spam/trash/starred/cat:…/label:…/of:…) — 사이드바 트리가 지정. const [folder, setFolder] = useState("inbox"); const [acctFilter, setAcctFilter] = useState(null); const [query, setQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); const [selId, setSelId] = useState(null); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [added, setAdded] = useState>({}); const [compose, setCompose] = useState(null); const [toasts, setToasts] = useState([]); const [syncing, setSyncing] = useState(false); const [logOpen, setLogOpen] = useState(false); const [selected, setSelected] = useState>(new Set()); const shellRef = useRef(null); const tid = useRef(0); const didMount = useRef(false); // URL 로 열린 메일을 표현 — /mail/ 세그먼트(옵셔널 캐치올). // 주소 갱신은 router 대신 history.replaceState(얕은 갱신) — router.replace 는 이 라우트를 // 리마운트시켜 folder/검색 상태가 날아가고 openMail 루프가 발생하기 때문. const params = useParams(); const routeId = Array.isArray(params.slug) ? params.slug[0] : undefined; // 검색은 서버측(백엔드 q 파라미터). 입력은 250ms 디바운스해 키 입력마다 호출하지 않음. useEffect(() => { const h = setTimeout(() => setDebouncedQuery(query), 250); return () => clearTimeout(h); }, [query]); const { emails, accounts, folders, mutate } = useMail(acctFilter, folder, debouncedQuery); const { mutate: globalMutate } = useSWRConfig(); const { confirm } = useDialog(); const { data: people } = useSWR("people", () => tasksApi.people(), { revalidateOnFocus: false, }); // 아리 정리 기준 시각(이 시각 이후 새 메일만 정리) + 새 안읽음 수(사이드바 배지). // 배지는 어느 폴더 보기에서도 필요하므로 항상 조회한다. day_start=로컬 자정을 넘겨 // '오늘 온 새 메일' 수(new_total/screened)를 클라 TZ 기준으로 산출(서버는 UTC). const { data: baseline, mutate: mutateBaseline } = useSWR( "mail/triage/baseline", () => { const d = new Date(); d.setHours(0, 0, 0, 0); return mailApi.triageBaseline(d.toISOString()); }, { revalidateOnFocus: false, refreshInterval: 30000 }, ); // 오늘 아리 자동 정리 실행 로그('한 일' 피드) — 실제로 돌았는지/에러났는지 눈으로 확인용. const { data: triageLog } = useSWR( "mail/triage/log", () => { const d = new Date(); d.setHours(0, 0, 0, 0); return mailApi.triageLog(d.toISOString()); }, { revalidateOnFocus: false, refreshInterval: 30000 }, ); const peopleMap = useMemo>(() => { const m: Record = {}; (people || []).forEach((p) => (m[p.id] = p)); return m; }, [people]); const accMap = useMemo>(() => { const m: Record = {}; (accounts || []).forEach((a) => (m[a.id] = a)); return m; }, [accounts]); useEffect(() => { const el = shellRef.current; const id = requestAnimationFrame(() => el && el.classList.add("entered")); return () => cancelAnimationFrame(id); }, [emails]); const toast = (msg: string, tone: string) => { const id = ++tid.current; setToasts((t) => [...t, { id, msg, tone }]); setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600); }; // 목록 + 사이드바 배지(계정/폴더 안읽음)까지 함께 갱신. // 읽음/별표/보관/삭제 등은 백엔드가 즉시 반영하지만, 계정/폴더 SWR 키는 별도라 // mutate() 만으론 사이드바 숫자가 안 줄어든다 → 함께 revalidate. const revalidateAll = async () => { await Promise.all([ mutate(), globalMutate("mail/accounts"), globalMutate("mail/folders"), globalMutate("mail/triage/baseline"), // 아리 정리 배지(새 안읽음) ]); }; const openMail = async (id: string) => { // 보낸편지함/임시보관함 행(ob-…)은 상세 조회 대상이 아니라 목록 표시 전용. if (folder === "sent" || folder === "drafts") return; setSelId(id); setDetail(null); setDetailLoading(true); try { const d = await mailApi.detail(id); setDetail(d); } catch { toast("메일을 불러오지 못했어요", "coral"); } finally { setDetailLoading(false); revalidateAll(); // 읽음 처리 + 사이드바 배지 반영 } }; // URL → 상세: routeId(주소의 )가 바뀌면 해당 메일을 연다(직접 입력·뒤로가기·딥링크). useEffect(() => { if (!routeId) { setSelId(null); setDetail(null); return; } if (routeId === selId) return; // 이미 열려 있음 openMail(routeId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [routeId]); // 상세 → URL: selId 가 바뀌면 주소를 맞춘다(초기 마운트는 위 effect 가 처리하므로 skip). useEffect(() => { if (!didMount.current) { didMount.current = true; return; } const target = selId ? `/mail/${selId}` : "/mail"; if (window.location.pathname !== target) window.history.replaceState(null, "", target); }, [selId]); // 사이드바 트리 선택 = (계정 범위, 폴더 슬러그) 동시 지정. const pickScope = (acct: string | null, f: string) => { setAcctFilter(acct); setFolder(f); setSelId(null); setDetail(null); setSelected(new Set()); }; const onStar = async (id: string) => { const cur = emails?.find((m) => m.id === id); const next = !(cur?.starred ?? detail?.starred ?? false); // 낙관적 토글 → 서버 반영 → 실패 시 롤백 if (detail && detail.id === id) setDetail({ ...detail, starred: next }); mutate((list) => (list || []).map((m) => (m.id === id ? { ...m, starred: next } : m)), false); try { await mailApi.star(id, next); revalidateAll(); } catch (e) { if (detail && detail.id === id) setDetail({ ...detail, starred: !next }); revalidateAll(); toast(e instanceof Error ? e.message : "별표를 바꾸지 못했어요", "coral"); } }; const onArchive = async (id: string) => { setSelId(null); setDetail(null); mutate((list) => (list || []).filter((m) => m.id !== id), false); try { await mailApi.archive(id); revalidateAll(); toast("보관함으로 옮겼어요", "green"); } catch (e) { revalidateAll(); toast(e instanceof Error ? e.message : "보관하지 못했어요", "coral"); } }; const onMarkUnread = async (id: string) => { // 안 읽음 처리 → 목록 낙관적 갱신 → 리더 닫고 목록으로 복귀 mutate((list) => (list || []).map((m) => (m.id === id ? { ...m, read: false } : m)), false); setSelId(null); setDetail(null); try { await mailApi.setRead(id, false); revalidateAll(); toast("안 읽음으로 표시했어요", "blue"); } catch (e) { revalidateAll(); toast(e instanceof Error ? e.message : "안 읽음으로 표시하지 못했어요", "coral"); } }; const onDelete = async (id: string) => { const ok = await confirm({ title: "메일 삭제", message: "이 메일을 삭제할까요?", confirmText: "삭제", tone: "danger", }); if (!ok) return; setSelId(null); setDetail(null); mutate((list) => (list || []).filter((m) => m.id !== id), false); try { await mailApi.remove(id); revalidateAll(); toast("메일을 삭제했어요", "green"); } catch (e) { revalidateAll(); toast(e instanceof Error ? e.message : "삭제하지 못했어요", "coral"); } }; // ── 여러 메일 선택 + 일괄 처리 ── const toggleSelect = (id: string) => setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const clearSelected = () => setSelected(new Set()); const onBulk = async (action: string) => { const ids = [...selected]; if (!ids.length) return; if (action === "trash") { const ok = await confirm({ title: "메일 삭제", message: `${ids.length}개 메일을 삭제할까요?`, confirmText: "삭제", tone: "danger", }); if (!ok) return; } // 즉시 목록에서 반영(삭제/보관은 사라지고, 읽음/별표는 토글) mutate((list) => { if (action === "trash" || action === "archive") return (list || []).filter((m) => !selected.has(m.id)); return (list || []).map((m) => selected.has(m.id) ? { ...m, read: action === "read" ? true : action === "unread" ? false : m.read, starred: action === "star" ? true : action === "unstar" ? false : m.starred, } : m, ); }, false); clearSelected(); try { const res = await mailApi.bulk(ids, action); revalidateAll(); toast(`${res.count}개 메일을 처리했어요`, "green"); } catch (e) { revalidateAll(); toast(e instanceof Error ? e.message : "일괄 처리에 실패했어요", "coral"); } }; // 전체 답장 / 전달 — 기존 발송(결재) 경로 재사용해 Compose 채움. const onReplyAll = (m: EmailDetail) => { const sender = peopleMap[m.from]; const to = sender?.email ? `${sender.name} <${sender.email}>` : (sender?.name ?? m.from); setCompose({ from: m.account, to, subject: "Re: " + m.subject, body: "", inReplyTo: m.id, }); }; const onForward = (m: EmailDetail) => { const quoted = (m.body || []).map((p) => "> " + p).join("\n"); setCompose({ from: m.account, to: "", subject: "Fwd: " + m.subject, body: `\n\n---------- 전달된 메일 ----------\n${quoted}`, }); }; // 추출(할일/일정/파일) — mailId 지정(없으면 현재 열린 메일 selId). 리더·아리비서 공용. const doAddFor = async (mailId: string, key: string, kind: ExtractKind, index: number) => { if (added[key]) return; setAdded((a) => ({ ...a, [key]: true })); // 낙관적 try { const res = await mailApi.extract(mailId, kind, index); toast(res.message, kind === "event" ? "coral" : kind === "file" ? "violet" : "blue"); if (kind === "task") globalMutate("/api/tasks"); if (kind === "event") globalMutate("/api/calendar"); } catch { setAdded((a) => ({ ...a, [key]: false })); // 롤백 toast("추가하지 못했어요", "coral"); } }; const doAdd = (key: string, kind: ExtractKind, index: number) => doAddFor(selId!, key, kind, index); // 아리 정리 카드 기각 — 다이제스트에서만 빼고(받은편지함엔 잔류) 배지/목록 갱신. const onDismissCard = async (id: string) => { mutate((list) => (list || []).filter((m) => m.id !== id), false); // 낙관적 제거 try { await mailApi.triageDismiss(id); await Promise.all([mutateBaseline(), globalMutate("mail/triage/baseline")]); toast("이 메일은 정리에서 뺐어요", "blue"); } catch { await mutate(); // 롤백(서버 상태로 복원) toast("기각하지 못했어요", "coral"); } }; const addAll = async (m: EmailRow) => { if (!m.ai) return; const next = { ...added }; m.ai.tasks.forEach((_, i) => (next["t:" + m.id + ":" + i] = true)); m.ai.events.forEach((_, i) => (next["e:" + m.id + ":" + i] = true)); if (m.ai.file) next["f:" + m.id] = true; setAdded(next); try { const res = await mailApi.extractAll(m.id); toast(res.message, "green"); globalMutate("/api/tasks"); globalMutate("/api/calendar"); } catch { toast("적용하지 못했어요", "coral"); } }; // 답장: AriPanel/Triage 의 reply 선택 → 서버 초안(AI) + 원본 인용 → Compose 채움 const onReply = async ( m: EmailRow | EmailDetail, replyIndex: number | null, _r: AiReply | null, ) => { void _r; try { const d = await mailApi.replyDraft(m.id, replyIndex); // 원본 본문: 열린 상세(detail) 우선, 없으면 m 자체가 상세면 그 body 사용 const bodyLines = detail && detail.id === m.id ? detail.body : "body" in m && Array.isArray(m.body) ? m.body : []; const quoted = bodyLines.map((p) => "> " + p).join("\n"); const draft = d.body || ""; const body = quoted ? `${draft}\n\n--------\n원본 메일:\n${quoted}` : draft; setCompose({ from: d.from_account, to: d.to, subject: d.subject, body, inReplyTo: m.id, }); } catch { toast("초안을 만들지 못했어요", "coral"); } }; const onSend = async (c: ComposeState) => { try { const res = await mailApi.send({ from_account: c.from, to: c.to ?? "", cc: c.cc ?? "", bcc: c.bcc ?? "", subject: c.subject ?? "", body: c.body ?? "", in_reply_to: c.inReplyTo ?? null, attachments: c.attachments ?? [], }); setCompose(null); toast(res.message, "violet"); // "결재함에서 확인하세요" globalMutate("/api/approvals"); } catch { toast("보내지 못했어요", "coral"); } }; if (!emails || !accounts || !folders) { return (