You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

614 lines
22 KiB
TypeScript

// 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 { 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<string, string> = {
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<string>("inbox");
const [acctFilter, setAcctFilter] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [selId, setSelId] = useState<string | null>(null);
const [detail, setDetail] = useState<EmailDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [added, setAdded] = useState<Record<string, boolean>>({});
const [compose, setCompose] = useState<ComposeState | null>(null);
const [toasts, setToasts] = useState<Toast[]>([]);
const [syncing, setSyncing] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const shellRef = useRef<HTMLDivElement>(null);
const tid = useRef(0);
const didMount = useRef(false);
// URL 로 열린 메일을 표현 — /mail/<id> 의 <id> 세그먼트(옵셔널 캐치올).
// 주소 갱신은 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<Person[]>("people", () => tasksApi.people(), {
revalidateOnFocus: false,
});
// 아리 정리 기준 시각(이 시각 이후 새 메일만 정리) + 새 안읽음 수(사이드바 배지).
// 배지는 어느 폴더 보기에서도 필요하므로 항상 조회한다.
const { data: baseline, mutate: mutateBaseline } = useSWR(
"mail/triage/baseline",
() => mailApi.triageBaseline(),
{ revalidateOnFocus: false, refreshInterval: 30000 },
);
const peopleMap = useMemo<Record<string, Person>>(() => {
const m: Record<string, Person> = {};
(people || []).forEach((p) => (m[p.id] = p));
return m;
}, [people]);
const accMap = useMemo<Record<string, MailAccount>>(() => {
const m: Record<string, MailAccount> = {};
(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(주소의 <id>)가 바뀌면 해당 메일을 연다(직접 입력·뒤로가기·딥링크).
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);
// 아리 비서: 새 메일을 가져와(sync) 미분석분을 LLM 으로 정리. 먼저 동기화해
// 기준 시각 이후 새로 도착한 메일을 받고, 작은 배치(2통)로 나눠 반복 분석 —
// 요청을 짧게 유지하고 라운드마다 목록을 갱신해 카드를 점진적으로 노출한다.
const runAnalyze = async () => {
if (analyzing) return;
setAnalyzing(true);
let total = 0;
try {
await mailApi.sync().catch(() => {}); // 새 메일 먼저 가져오기(실패해도 분석 진행)
await revalidateAll();
for (let round = 0; round < 8; round++) {
const res = await mailApi.triageAnalyze(2);
total += res.analyzed;
await mutate(); // 라운드마다 카드 갱신
if (res.remaining === 0 || res.analyzed === 0) break;
}
toast(total > 0 ? `새 메일 ${total}통을 정리했어요` : "정리할 새 메일이 없어요", "blue");
} catch {
if (total > 0) await mutate();
toast("정리하지 못했어요 (아리 모델 확인)", "coral");
} finally {
setAnalyzing(false);
}
};
// 기준 시각 재설정 — 지금 이후 도착하는 새 메일부터 다시 정리.
const onResetBaseline = async () => {
try {
await mailApi.triageBaselineReset();
await Promise.all([mutateBaseline(), mutate()]);
toast("지금부터 도착하는 새 메일만 정리할게요", "blue");
} catch {
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 (
<div className="mailpage">
<div className="mwork" aria-busy="true">
<aside className="subnav" />
<div className="mlist" />
<div className="reader" />
</div>
</div>
);
}
// 목록은 서버가 폴더 슬러그로 이미 필터·정렬 — 그대로 표시.
const visible = emails;
// 폴더를 계정별로 그룹화(사이드바 계정 노드 서브트리). 정렬 키대로.
const foldersByAccount: Record<string, typeof folders> = {};
folders.forEach((f) => (foldersByAccount[f.account] ||= []).push(f));
Object.values(foldersByAccount).forEach((list) =>
list.sort((a, b) => a.sort_order - b.sort_order),
);
// 통합 받은편지함 노드: 계정 공통(시스템·카테고리) 폴더만 슬러그별 합산(커스텀/라벨 제외).
const unifiedMap: Record<string, (typeof folders)[number]> = {};
folders.forEach((f) => {
// 통합 상단 목록은 공통 시스템 폴더만(받은편지함/별표/보낸/임시/보관/스팸/휴지통).
// 카테고리(Gmail 프로모션·Outlook 기타)/라벨/커스텀은 각 계정 노드에서만 표시.
if (f.kind !== "system") return;
const cur = unifiedMap[f.slug];
if (cur) {
cur.unread += f.unread;
cur.total += f.total;
} else unifiedMap[f.slug] = { ...f, account: "" };
});
const unifiedFolders = Object.values(unifiedMap).sort((a, b) => a.sort_order - b.sort_order);
// 아리 정리 배지 = 기준 시각 이후 도착한 새 메일 수(읽음 무관) — 백엔드가 baseline 으로 산출.
const smartCount = baseline?.count ?? 0;
// 사이드바 트리 배지(집중 안읽음)는 계정 목록의 unread(백엔드 산출)에서 — 현재 보기와 무관하게 정확.
const accUnread = (id: string) => accMap[id]?.unread ?? 0;
const unifiedUnread = accounts.reduce((sum, a) => sum + a.unread, 0);
// 기타(Outlook other) 안읽음 — 통합/계정별. 사이드바 기타 행·받은편지함 탭 카운트에 사용.
const unifiedOthersUnread = accounts.reduce((sum, a) => sum + (a.others ?? 0), 0);
const focusedUnread = acctFilter ? (accMap[acctFilter]?.unread ?? 0) : unifiedUnread;
const othersUnread = acctFilter ? (accMap[acctFilter]?.others ?? 0) : unifiedOthersUnread;
const accColor = (id: string) => {
const i = accounts.findIndex((a) => a.id === id);
return ACC_PALETTE[(i < 0 ? 0 : i) % ACC_PALETTE.length];
};
// 목록 제목: 폴더 슬러그 → 표시명(시스템은 META, 커스텀/카테고리/라벨은 폴더명에서).
const curFolder =
(acctFilter ? foldersByAccount[acctFilter] : unifiedFolders)?.find((f) => f.slug === folder) ||
folders.find((f) => f.slug === folder);
const listTitle = FOLDER_META[folder] ?? curFolder?.name ?? "받은편지함";
const sel = detail && detail.id === selId ? detail : null;
// 아리 비서(정리) 보기 = smart 폴더. 메일 리스트 대신 비서 다이제스트를 보여준다.
const digestView = folder === "smart";
const readerEl = (
<Reader
m={sel}
person={sel ? peopleMap[sel.from] : undefined}
account={sel ? accMap[sel.account] : undefined}
added={added}
loading={detailLoading}
onAdd={doAdd}
onReply={onReply}
onReplyAll={onReplyAll}
onForward={onForward}
onAddAll={addAll}
onArchive={onArchive}
onStar={onStar}
onMarkUnread={onMarkUnread}
onDelete={onDelete}
onOpenMsg={openMail}
/>
);
return (
<div className="mailpage" ref={shellRef}>
<div
className={
"mwork" + (digestView ? " digest" : "") + (digestView && selId ? " reading" : "")
}
>
<MailSidebar
accounts={accounts}
folder={folder}
acctFilter={acctFilter}
smartCount={smartCount}
unifiedUnread={unifiedUnread}
accUnread={accUnread}
unifiedFolders={unifiedFolders}
foldersByAccount={foldersByAccount}
accColor={accColor}
onCompose={() => setCompose({ from: acctFilter || accounts[0]?.id || "" })}
onSelectSmart={() => pickScope(null, "smart")}
onSelectScope={pickScope}
onToast={toast}
/>
{digestView ? (
<>
<Triage
emails={emails}
people={peopleMap}
accColor={accColor}
added={added}
analyzing={analyzing}
since={baseline?.since}
onAnalyze={runAnalyze}
onResetBaseline={onResetBaseline}
onOpen={openMail}
onAdd={doAddFor}
onAddAll={addAll}
onReply={onReply}
/>
{selId && readerEl}
</>
) : (
<>
<MailList
title={listTitle}
metaLabel={acctFilter ? (accMap[acctFilter]?.name ?? "") + " · " : ""}
count={visible.length}
folder={folder}
focusedUnread={focusedUnread}
othersUnread={othersUnread}
onSelectTab={(slug) => pickScope(acctFilter, slug)}
query={query}
syncing={syncing}
emails={visible}
people={peopleMap}
accounts={accMap}
selId={selId}
hasSel={!!selId}
selected={selected}
onToggleSelect={toggleSelect}
onClearSelected={clearSelected}
onBulk={onBulk}
onQuery={setQuery}
onSync={async () => {
setSyncing(true);
try {
const res = await mailApi.sync();
await revalidateAll();
toast(
res.upserted > 0 ? `새 메일 ${res.upserted}통을 가져왔어요` : "새 메일이 없어요",
"blue",
);
} catch {
toast("메일을 가져오지 못했어요", "coral");
} finally {
setSyncing(false);
}
}}
onOpen={openMail}
onStar={onStar}
accColor={accColor}
/>
{readerEl}
</>
)}
</div>
{compose && (
<Compose
init={compose}
accounts={accounts}
onClose={() => setCompose(null)}
onSend={onSend}
/>
)}
<div className="toast-wrap">
{toasts.map((t) => (
<div className="toast" key={t.id}>
<span className="t-dot" style={{ background: toneColor(t.tone) }} />
{t.msg}
</div>
))}
</div>
</div>
);
}