Files
vexplor_dev/frontend/app/(main)/mail/imap/page.tsx
2026-04-01 15:29:00 +09:00

1106 lines
47 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef } from "react";
import dynamic from "next/dynamic";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { ChevronsUpDown, Check } from "lucide-react";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Mail,
Inbox,
RefreshCw,
Plus,
Settings,
Trash2,
Loader2,
Search,
ChevronRight,
Paperclip,
AlertCircle,
CheckCircle,
X,
Reply,
Forward,
FolderOpen,
Send,
Download,
Eye,
EyeOff,
} from "lucide-react";
import DOMPurify from "isomorphic-dompurify";
import {
getUserMailAccounts,
createUserMailAccount,
updateUserMailAccount,
deleteUserMailAccount,
testUserMailConnectionDirect,
streamUserMails,
getUserMailDetail,
markUserMailAsRead,
deleteUserMail,
getUserMailFolders,
moveUserMail,
sendUserMail,
getUserMailAttachments,
downloadAttachment,
streamFolderMails,
UserMailAccount,
ReceivedMail,
MailDetail,
CreateUserMailAccountDto,
MailFolder,
SendMailDto,
} from "@/lib/api/userMail";
import type ComposeDialogType from "./ComposeDialog";
const ComposeDialogDynamic = dynamic(() => import("./ComposeDialog"), { ssr: false }) as typeof ComposeDialogType;
const IMAP_PRESETS = [
{ label: "직접 입력", value: "custom", host: "", port: 993, useTls: true },
{ label: "Gmail", value: "gmail", host: "imap.gmail.com", port: 993, useTls: true },
{ label: "Naver", value: "naver", host: "imap.naver.com", port: 993, useTls: true },
{ label: "Outlook / Hotmail", value: "outlook", host: "outlook.office365.com", port: 993, useTls: true },
{ label: "Kakao", value: "kakao", host: "imap.kakao.com", port: 993, useTls: true },
{ label: "Daum", value: "daum", host: "imap.daum.net", port: 993, useTls: true },
];
const DEFAULT_FORM: CreateUserMailAccountDto = {
displayName: "",
email: "",
protocol: "imap",
host: "",
port: 993,
useTls: true,
username: "",
password: "",
};
export default function ImapMailPage() {
const [accounts, setAccounts] = useState<UserMailAccount[]>([]);
const [selectedAccount, setSelectedAccount] = useState<UserMailAccount | null>(null);
const [mailsMap, setMailsMap] = useState<Map<number, ReceivedMail[]>>(new Map());
const [loadingMap, setLoadingMap] = useState<Map<number, boolean>>(new Map());
const [minSeqnoMap, setMinSeqnoMap] = useState<Map<number, number | null>>(new Map());
const [loadingMoreMap, setLoadingMoreMap] = useState<Map<number, boolean>>(new Map());
const [selectedMail, setSelectedMail] = useState<MailDetail | null>(null);
const [downloadProgress, setDownloadProgress] = useState<Record<number, number>>({});
const [loadingAccounts, setLoadingAccounts] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [showDialog, setShowDialog] = useState(false);
const [editingAccount, setEditingAccount] = useState<UserMailAccount | null>(null);
const [form, setForm] = useState<CreateUserMailAccountDto>(DEFAULT_FORM);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [testing, setTesting] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [formErrors, setFormErrors] = useState<Partial<Record<'displayName' | 'email' | 'host' | 'username' | 'password', string>>>({});
// New states
const [folders, setFolders] = useState<MailFolder[]>([]);
const [currentFolder, setCurrentFolder] = useState<string>("INBOX");
const [composeOpen, setComposeOpen] = useState(false);
const [composeMode, setComposeMode] = useState<"new" | "reply" | "forward">("new");
const [composeTo, setComposeTo] = useState("");
const [composeCc, setComposeCc] = useState("");
const [composeSubject, setComposeSubject] = useState("");
const [composeInitialHtml, setComposeInitialHtml] = useState("");
const [composeInReplyTo, setComposeInReplyTo] = useState("");
const [composeReferences, setComposeReferences] = useState("");
const [composeSending, setComposeSending] = useState(false);
const [pendingDeleteMail, setPendingDeleteMail] = useState<ReceivedMail | null>(null);
const [pendingDeleteAccount, setPendingDeleteAccount] = useState<UserMailAccount | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [hostPopoverOpen, setHostPopoverOpen] = useState(false);
const detailCacheRef = useRef<Map<string, MailDetail>>(new Map());
const prefetchingRef = useRef<Set<string>>(new Set());
const mailsMapRef = useRef<Map<number, ReceivedMail[]>>(new Map());
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
// 현재 선택 계정 기준 파생값
const mails = selectedAccount ? (mailsMap.get(selectedAccount.id) || []) : [];
const loadingMails = selectedAccount ? (loadingMap.get(selectedAccount.id) ?? false) : false;
const minSeqno = selectedAccount ? (minSeqnoMap.get(selectedAccount.id) ?? null) : null;
const loadingMore = selectedAccount ? (loadingMoreMap.get(selectedAccount.id) ?? false) : false;
const imapAccounts = accounts.filter((a) => a.protocol === "imap");
useEffect(() => {
loadAccounts();
}, []);
useEffect(() => {
if (selectedAccount) {
setSelectedMail(null);
setCurrentFolder("INBOX");
loadFolders(selectedAccount);
// 아직 로딩 안 됐으면 시작
if (!mailsMap.has(selectedAccount.id) && !loadingMap.get(selectedAccount.id)) {
startStream(selectedAccount);
}
}
}, [selectedAccount]);
async function loadAccounts() {
setLoadingAccounts(true);
try {
const data = await getUserMailAccounts();
setAccounts(data);
// 모든 계정 동시 프리로드
const imapAccts = data.filter((a: UserMailAccount) => a.protocol === "imap");
for (const account of imapAccts) {
startStream(account);
}
} catch (e) {
console.error("계정 목록 로드 실패:", e);
} finally {
setLoadingAccounts(false);
}
}
async function loadFolders(account: UserMailAccount) {
try {
const data = await getUserMailFolders(account.id);
setFolders(data);
} catch {
setFolders([]);
}
}
async function prefetchDetail(account: UserMailAccount, mail: ReceivedMail) {
if (detailCacheRef.current.has(mail.id)) return;
if (prefetchingRef.current.has(mail.id)) return;
prefetchingRef.current.add(mail.id);
try {
const seqno = parseInt(mail.id.split("-").pop() || "0");
if (!seqno) return;
const detail = await getUserMailDetail(account.id, seqno);
if (detail) detailCacheRef.current.set(mail.id, detail);
} catch {
// 프리로드 실패 무시
} finally {
prefetchingRef.current.delete(mail.id);
}
}
function startStream(account: UserMailAccount, before: number | null = null, append = false) {
// 이미 로딩 중이면 스킵 (초기 로드 한정)
if (!append && loadingMap.get(account.id)) return;
setLoadingMap((prev) => new Map(prev).set(account.id, true));
const cancel = streamUserMails(
account.id, 20, before,
(mail) => {
setMailsMap((prev) => {
const next = new Map(prev);
const existing = next.get(account.id) || [];
const updated = append
? [...existing, mail]
: [mail, ...existing.filter((m) => m.id !== mail.id)];
next.set(account.id, updated);
mailsMapRef.current = next;
return next;
});
setMinSeqnoMap((prev) => {
const seqno = parseInt(mail.id.split("-").pop() || "0");
const current = prev.get(account.id) ?? null;
return new Map(prev).set(account.id, current === null ? seqno : Math.min(current, seqno));
});
setLoadingMap((prev) => new Map(prev).set(account.id, false));
},
() => {
setLoadingMap((prev) => new Map(prev).set(account.id, false));
setLoadingMoreMap((prev) => new Map(prev).set(account.id, false));
// 상위 5개 자동 프리로드 (순차, Gmail은 throttling으로 느려지므로 간격 늘림)
const currentMails = mailsMapRef.current.get(account.id) || [];
const top5 = currentMails.slice(0, 5);
const isGmail = account.host.toLowerCase().includes('gmail') || account.email.toLowerCase().includes('@gmail');
const interval = isGmail ? 800 : 100;
top5.forEach((m, i) => {
setTimeout(() => prefetchDetail(account, m), i * interval);
});
},
(err) => {
setLoadingMap((prev) => new Map(prev).set(account.id, false));
setLoadingMoreMap((prev) => new Map(prev).set(account.id, false));
console.error("스트리밍 오류:", err);
}
);
return cancel;
}
function handleFolderClick(folder: string) {
if (!selectedAccount) return;
setCurrentFolder(folder);
setSelectedMail(null);
if (folder === "INBOX") {
setMailsMap((prev) => { const n = new Map(prev); n.delete(selectedAccount.id); return n; });
setMinSeqnoMap((prev) => { const n = new Map(prev); n.delete(selectedAccount.id); return n; });
startStream(selectedAccount);
} else {
setMailsMap((prev) => { const n = new Map(prev); n.set(selectedAccount.id, []); return n; });
setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, true));
streamFolderMails(
selectedAccount.id, folder, 20, null,
(mail) => {
setMailsMap((prev) => {
const n = new Map(prev);
n.set(selectedAccount.id, [...(n.get(selectedAccount.id) || []), mail]);
return n;
});
setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false));
},
() => setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false)),
(err) => { setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false)); console.error(err); }
);
}
}
async function handleMailClick(mail: ReceivedMail) {
if (!selectedAccount) return;
const seqno = parseInt(mail.id.split("-").pop() || "0");
// 캐시 히트 시 즉시 표시
const cached = detailCacheRef.current.get(mail.id);
if (cached) {
setSelectedMail(cached);
// 첨부파일 로드
if (seqno) {
}
if (!mail.isRead) {
markUserMailAsRead(selectedAccount.id, seqno).then(() => loadFolders(selectedAccount)).catch(() => {});
setMailsMap((prev) => {
const next = new Map(prev);
const mails = (next.get(selectedAccount.id) || []).map((m) =>
m.id === mail.id ? { ...m, isRead: true } : m
);
next.set(selectedAccount.id, mails);
mailsMapRef.current = next;
return next;
});
}
return;
}
// 캐시 미스 시 fetch
setLoadingDetail(true);
try {
const detail = await getUserMailDetail(selectedAccount.id, seqno);
if (detail) {
detailCacheRef.current.set(mail.id, detail);
setSelectedMail(detail);
}
// 첨부파일 로드
if (seqno) {
}
if (!mail.isRead) {
await markUserMailAsRead(selectedAccount.id, seqno);
setMailsMap((prev) => {
const next = new Map(prev);
const mails = (next.get(selectedAccount.id) || []).map((m) =>
m.id === mail.id ? { ...m, isRead: true } : m
);
next.set(selectedAccount.id, mails);
mailsMapRef.current = next;
return next;
});
loadFolders(selectedAccount);
}
} catch (e) {
console.error("메일 상세 로드 실패:", e);
} finally {
setLoadingDetail(false);
}
}
async function handleDeleteMail(mail: ReceivedMail) {
if (!selectedAccount) return;
setPendingDeleteMail(mail);
}
async function confirmDeleteMail() {
if (!selectedAccount || !pendingDeleteMail) return;
const mail = pendingDeleteMail;
setPendingDeleteMail(null);
const seqno = parseInt(mail.id.split("-").pop() || "0");
try {
await deleteUserMail(selectedAccount.id, seqno);
setMailsMap((prev) => {
const next = new Map(prev);
const existing = next.get(selectedAccount.id) || [];
next.set(selectedAccount.id, existing.filter((m) => m.id !== mail.id));
return next;
});
if (selectedMail?.id === mail.id) setSelectedMail(null);
loadFolders(selectedAccount);
} catch (e: any) {
toast.error("메일 삭제 실패: " + e.message);
}
}
async function handleMove(mail: ReceivedMail, targetFolder: string) {
if (!selectedAccount) return;
const seqno = parseInt(mail.id.split("-").pop() || "0");
try {
await moveUserMail(selectedAccount.id, seqno, targetFolder);
setMailsMap((prev) => {
const next = new Map(prev);
next.set(selectedAccount.id, (next.get(selectedAccount.id) || []).filter((m) => m.id !== mail.id));
return next;
});
if (selectedMail?.id === mail.id) setSelectedMail(null);
} catch (e) {
console.error("메일 이동 실패:", e);
}
}
function handleReply() {
if (!selectedMail) return;
setComposeMode("reply");
setComposeTo(selectedMail.from);
setComposeSubject(`Re: ${selectedMail.subject.replace(/^Re:\s*/i, "")}`);
setComposeInReplyTo(selectedMail.messageId);
setComposeReferences(selectedMail.messageId);
const dateStr = new Date(selectedMail.date).toLocaleString("ko-KR");
setComposeInitialHtml(
`<br><br><div style="border-left:2px solid #ccc;padding-left:8px;color:#666">
<div>${dateStr}, ${selectedMail.from} 작성:</div>
<blockquote>${selectedMail.htmlBody || selectedMail.textBody}</blockquote>
</div>`
);
setComposeOpen(true);
}
function handleForward() {
if (!selectedMail) return;
setComposeMode("forward");
setComposeTo("");
setComposeSubject(`Fwd: ${selectedMail.subject.replace(/^Fwd:\s*/i, "")}`);
setComposeInReplyTo("");
setComposeReferences("");
setComposeInitialHtml(
`<br><br><div>---------- 전달된 메일 ----------</div>
<div>보낸사람: ${selectedMail.from}</div>
<div>날짜: ${new Date(selectedMail.date).toLocaleString("ko-KR")}</div>
<div>제목: ${selectedMail.subject}</div>
<div>받는사람: ${selectedMail.to}</div>
<br>${selectedMail.htmlBody || selectedMail.textBody}`
);
setComposeOpen(true);
}
function openAddDialog() {
setEditingAccount(null);
setForm(DEFAULT_FORM);
setTestResult(null);
setShowPassword(false);
setHostPopoverOpen(false);
setFormErrors({});
setShowDialog(true);
}
function openEditDialog(account: UserMailAccount) {
setEditingAccount(account);
setForm({
displayName: account.displayName,
email: account.email,
protocol: "imap",
host: account.host,
port: account.port,
useTls: account.useTls,
username: account.username,
password: "",
});
setTestResult(null);
setShowPassword(false);
setShowDialog(true);
}
function handleTlsToggle(checked: boolean) {
setForm((prev) => ({
...prev,
useTls: checked,
port: checked ? 993 : 143,
}));
}
async function handleSave() {
const errors: typeof formErrors = {};
if (!form.displayName.trim()) errors.displayName = "표시 이름을 입력하세요.";
if (!form.email.trim()) errors.email = "이메일 주소를 입력하세요.";
if (!form.host.trim()) errors.host = "IMAP 호스트를 입력하세요.";
if (!form.username.trim()) errors.username = "사용자명을 입력하세요.";
if (!editingAccount && !form.password.trim()) errors.password = "비밀번호를 입력하세요.";
if (Object.keys(errors).length > 0) { setFormErrors(errors); return; }
setFormErrors({});
setSaving(true);
setSaveError(null);
try {
if (editingAccount) {
await updateUserMailAccount(editingAccount.id, form);
} else {
await createUserMailAccount(form);
}
await loadAccounts();
setShowDialog(false);
} catch (e: any) {
const msg = e?.response?.data?.message || (e instanceof Error ? e.message : "저장 실패");
setSaveError(msg);
} finally {
setSaving(false);
}
}
async function handleDeleteAccount(account: UserMailAccount) {
setPendingDeleteAccount(account);
}
async function confirmDeleteAccount() {
if (!pendingDeleteAccount) return;
const account = pendingDeleteAccount;
setPendingDeleteAccount(null);
try {
await deleteUserMailAccount(account.id);
if (selectedAccount?.id === account.id) {
setSelectedAccount(null);
setSelectedMail(null);
}
setMailsMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; });
setMinSeqnoMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; });
setLoadingMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; });
setLoadingMoreMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; });
await loadAccounts();
} catch (e) {
console.error("계정 삭제 실패:", e);
}
}
async function handleTest() {
setTesting(true);
setTestResult(null);
try {
const result = await testUserMailConnectionDirect({
protocol: form.protocol,
host: form.host,
port: form.port,
useTls: form.useTls,
username: form.username,
password: form.password,
});
setTestResult(result);
} catch (e: unknown) {
setTestResult({
success: false,
message: e instanceof Error ? e.message : "연결 테스트 실패",
});
} finally {
setTesting(false);
}
}
const filteredMails = mails.filter(
(m) =>
m.subject.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.from.toLowerCase().includes(searchTerm.toLowerCase())
);
function formatDate(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const isToday =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
return isToday
? d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" })
: d.toLocaleDateString("ko-KR", { month: "short", day: "numeric" });
}
return (
<div className="flex flex-col h-full">
{/* 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold"> </h1>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => { setComposeMode("new"); setComposeInitialHtml(""); setComposeTo(""); setComposeCc(""); setComposeSubject(""); setComposeOpen(true); }}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
{/* 3단 패널 */}
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal" className="h-full">
{/* 계정 목록 */}
<ResizablePanel defaultSize={18} minSize={14}>
<div className="flex flex-col h-full border-r">
<div className="px-3 py-2 border-b text-xs font-medium text-muted-foreground flex items-center gap-1">
<Inbox className="h-3.5 w-3.5" />
{loadingAccounts && <Loader2 className="h-3 w-3 animate-spin" />}
<button
className="ml-auto p-0.5 hover:text-foreground text-muted-foreground"
onClick={openAddDialog}
title="계정 추가"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{imapAccounts.length === 0 && !loadingAccounts ? (
<div className="p-3 text-xs text-muted-foreground text-center">
</div>
) : (
imapAccounts.map((account) => (
<div
key={account.id}
className={`group flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-muted/50 border-b text-sm transition-colors ${
selectedAccount?.id === account.id ? "bg-muted" : ""
}`}
onClick={() => setSelectedAccount(account)}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{account.displayName}</div>
<div className="text-xs text-muted-foreground truncate">{account.email}</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
<button
className="p-0.5 hover:text-foreground text-muted-foreground"
onClick={(e) => { e.stopPropagation(); openEditDialog(account); }}
>
<Settings className="h-3.5 w-3.5" />
</button>
<button
className="p-0.5 hover:text-destructive text-muted-foreground"
onClick={(e) => { e.stopPropagation(); handleDeleteAccount(account); }}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))
)}
{/* 폴더 목록 */}
{selectedAccount && folders.length > 0 && (
<div className="mt-4">
<div className="text-xs font-semibold text-muted-foreground px-2 mb-1 flex items-center gap-1">
<FolderOpen className="w-3 h-3" />
</div>
{folders.map((folder) => (
<div
key={folder.path}
className={`px-2 py-1 text-sm cursor-pointer rounded hover:bg-accent flex justify-between items-center ${currentFolder === folder.path ? "bg-accent font-medium" : ""}`}
onClick={() => handleFolderClick(folder.path)}
>
<span>{folder.name}</span>
{folder.unseen > 0 && <Badge variant="secondary" className="text-xs">{folder.unseen}</Badge>}
</div>
))}
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
{/* 메일 목록 */}
<ResizablePanel defaultSize={32} minSize={22}>
<div className="flex flex-col h-full border-r">
<div className="px-3 py-2 border-b flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="pl-7 h-7 text-xs"
placeholder="제목, 발신자 검색"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{selectedAccount && (
<button
className="text-muted-foreground hover:text-foreground"
onClick={() => {
if (selectedAccount) {
setMailsMap((prev) => { const next = new Map(prev); next.delete(selectedAccount.id); return next; });
setMinSeqnoMap((prev) => { const next = new Map(prev); next.delete(selectedAccount.id); return next; });
if (currentFolder === "INBOX") {
startStream(selectedAccount);
} else {
handleFolderClick(currentFolder);
}
}
}}
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="flex-1 overflow-y-auto">
{!selectedAccount ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<Mail className="h-8 w-8 opacity-30" />
<p className="text-sm"> </p>
</div>
) : loadingMails && mails.length === 0 ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : filteredMails.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<Inbox className="h-8 w-8 opacity-30" />
<p className="text-sm">{searchTerm ? `"${searchTerm}" 검색 결과가 없습니다` : "메일이 없습니다"}</p>
</div>
) : (
<>
{filteredMails.map((mail) => (
<div
key={mail.id}
className={`group flex items-start gap-2 px-3 py-2.5 cursor-pointer hover:bg-muted/50 border-b transition-colors ${
selectedMail?.id === mail.id ? "bg-muted" : ""
}`}
onClick={() => handleMailClick(mail)}
onMouseEnter={() => {
if (!selectedAccount || detailCacheRef.current.has(mail.id)) return;
hoverTimerRef.current = setTimeout(() => {
prefetchDetail(selectedAccount, mail);
}, 300);
}}
onMouseLeave={() => {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
}}
>
<div className="mt-1">
{!mail.isRead ? (
<div className="h-2 w-2 rounded-full bg-blue-500" />
) : (
<div className="h-2 w-2 rounded-full bg-transparent border border-muted-foreground/30" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<span className={`text-xs truncate ${!mail.isRead ? "font-semibold" : "text-muted-foreground"}`}>
{mail.from}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{formatDate(mail.date)}
</span>
</div>
<div className={`text-sm truncate ${!mail.isRead ? "font-medium" : ""}`}>
{mail.subject || "(제목 없음)"}
</div>
<div className="flex items-center gap-1 mt-0.5">
<span className="text-xs text-muted-foreground truncate flex-1">
{mail.preview}
</span>
{mail.hasAttachments && (
<Paperclip className="h-3 w-3 text-muted-foreground shrink-0" />
)}
</div>
</div>
<button
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive text-muted-foreground mt-1"
onClick={(e) => { e.stopPropagation(); handleDeleteMail(mail); }}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
{loadingMore ? (
<div className="flex items-center justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : !loadingMore && mails.length >= 20 ? (
<button
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
onClick={() => {
if (selectedAccount) {
setLoadingMoreMap((prev) => new Map(prev).set(selectedAccount.id, true));
startStream(selectedAccount, minSeqno, true);
}
}}
>
</button>
) : null}
</>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
{/* 메일 상세 */}
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex flex-col h-full">
{loadingDetail ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : !selectedMail ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<ChevronRight className="h-8 w-8 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<>
<div className="px-4 py-3 border-b space-y-1">
<h2 className="font-semibold text-base">
{selectedMail.subject || "(제목 없음)"}
</h2>
<div className="text-xs text-muted-foreground space-y-0.5">
<div><span className="font-medium">From:</span> {selectedMail.from}</div>
<div><span className="font-medium">To:</span> {selectedMail.to}</div>
{selectedMail.cc && (
<div><span className="font-medium">CC:</span> {selectedMail.cc}</div>
)}
<div><span className="font-medium">Date:</span> {new Date(selectedMail.date).toLocaleString("ko-KR")}</div>
</div>
{selectedMail.attachments.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{selectedMail.attachments.map((att, i) => {
const seqno = selectedMail ? parseInt(selectedMail.id.split("-").pop() || "0") : 0;
const accountId = selectedAccount?.id || 0;
const progress = downloadProgress[i];
const isDownloading = progress !== undefined;
return (
<button key={i}
disabled={isDownloading}
onClick={async () => {
setDownloadProgress(p => ({ ...p, [i]: 0 }));
try {
const list = await getUserMailAttachments(accountId, seqno, currentFolder);
const matched = list.find(a => a.filename === att.filename) || list[i];
if (!matched) { toast.error('첨부파일 정보를 불러올 수 없습니다'); return; }
await downloadAttachment(
accountId, seqno, matched.partId, matched.filename, currentFolder,
(pct) => setDownloadProgress(p => ({ ...p, [i]: pct })),
matched.size
);
} catch (e: any) { toast.error(e.message); }
finally { setDownloadProgress(p => { const next = { ...p }; delete next[i]; return next; }); }
}}
className="relative inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-secondary hover:bg-accent border disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer min-w-[80px] overflow-hidden">
{isDownloading ? (
<>
<Loader2 className="h-3 w-3 animate-spin shrink-0" />
<span className="flex-1 truncate">{att.filename}</span>
<span className="shrink-0 font-mono">{progress}%</span>
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-primary/30 overflow-hidden">
<span className="block h-full bg-primary transition-all duration-200" style={{ width: `${progress}%` }} />
</span>
</>
) : (
<>
<Download className="h-3 w-3 shrink-0" />
<span className="truncate">{att.filename}</span>
</>
)}
</button>
);
})}
</div>
)}
{/* 답장/전달/이동/삭제 버튼 */}
<div className="flex gap-2 mt-2 flex-wrap">
<Button size="sm" variant="outline" onClick={handleReply}>
<Reply className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={handleForward}>
<Forward className="w-3 h-3 mr-1" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<FolderOpen className="w-3 h-3 mr-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{folders.filter((f) => f.path !== currentFolder).map((f) => (
<DropdownMenuItem key={f.path} onClick={() => handleMove(selectedMail, f.path)}>
{f.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button size="sm" variant="destructive" onClick={() => handleDeleteMail(selectedMail)}>
<Trash2 className="w-3 h-3 mr-1" />
</Button>
</div>
</div>
<div className="flex-1 overflow-hidden">
{selectedMail.htmlBody ? (
<iframe
srcDoc={DOMPurify.sanitize(selectedMail.htmlBody)}
className="w-full h-full border-0"
sandbox="allow-same-origin"
title="메일 본문"
/>
) : (
<div className="p-4 overflow-y-auto h-full">
<pre className="text-sm whitespace-pre-wrap font-sans">
{selectedMail.textBody}
</pre>
</div>
)}
</div>
</>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ComposeDialog */}
<ComposeDialogDynamic
open={composeOpen}
onOpenChange={setComposeOpen}
mode={composeMode}
to={composeTo} setTo={setComposeTo}
cc={composeCc} setCc={setComposeCc}
subject={composeSubject} setSubject={setComposeSubject}
initialHtml={composeInitialHtml} setInitialHtml={setComposeInitialHtml}
inReplyTo={composeInReplyTo}
references={composeReferences}
sending={composeSending} setSending={setComposeSending}
accountId={selectedAccount?.id ?? null}
accounts={imapAccounts}
/>
{/* 계정 추가/편집 다이얼로그 */}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{editingAccount ? "IMAP 계정 편집" : "IMAP 계정 추가"}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={form.displayName}
onChange={(e) => { setForm((p) => ({ ...p, displayName: e.target.value })); setFormErrors((p) => ({ ...p, displayName: undefined })); }}
placeholder="내 Gmail"
className={formErrors.displayName ? "border-destructive" : ""}
/>
{formErrors.displayName && <p className="text-xs text-destructive">{formErrors.displayName}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="email"
value={form.email}
onChange={(e) => { setForm((p) => ({ ...p, email: e.target.value, username: e.target.value })); setFormErrors((p) => ({ ...p, email: undefined })); }}
placeholder="user@example.com"
className={formErrors.email ? "border-destructive" : ""}
/>
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">IMAP </Label>
<Popover open={hostPopoverOpen} onOpenChange={setHostPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between font-normal">
<span className={cn("truncate", !form.host && "text-muted-foreground")}>
{form.host || "imap.gmail.com"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[220px] p-0 z-[10002]" align="start">
<Command>
<CommandInput
placeholder="호스트 직접 입력..."
value={form.host}
onValueChange={(v) => { setForm((p) => ({ ...p, host: v })); setFormErrors((p) => ({ ...p, host: undefined })); }}
/>
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup>
{IMAP_PRESETS.filter((p) => p.value !== "custom").map((preset) => (
<CommandItem
key={preset.value}
value={preset.host}
onSelect={() => {
setForm((p) => ({ ...p, host: preset.host, port: preset.port, useTls: preset.useTls }));
setHostPopoverOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", form.host === preset.host ? "opacity-100" : "opacity-0")} />
<div>
<div className="text-sm font-medium">{preset.label}</div>
<div className="text-xs text-muted-foreground">{preset.host}</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{formErrors.host && <p className="text-xs text-destructive">{formErrors.host}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="number"
value={form.port}
onChange={(e) => setForm((p) => ({ ...p, port: parseInt(e.target.value) || 993 }))}
/>
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">TLS/SSL </Label>
<Switch checked={form.useTls} onCheckedChange={handleTlsToggle} />
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={form.username}
onChange={(e) => { setForm((p) => ({ ...p, username: e.target.value })); setFormErrors((p) => ({ ...p, username: undefined })); }}
placeholder="user@example.com"
className={formErrors.username ? "border-destructive" : ""}
/>
{formErrors.username && <p className="text-xs text-destructive">{formErrors.username}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs"> {editingAccount && "(변경 시에만 입력)"}</Label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={form.password}
onChange={(e) => { setForm((p) => ({ ...p, password: e.target.value })); setFormErrors((p) => ({ ...p, password: undefined })); }}
onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }}
placeholder="••••••••"
className={`pr-9 ${formErrors.password ? "border-destructive" : ""}`}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{formErrors.password && <p className="text-xs text-destructive">{formErrors.password}</p>}
</div>
{editingAccount && (
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleTest}
disabled={testing}
>
{testing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
) : null}
</Button>
{testResult && (
<div className={`mt-2 text-xs flex items-center gap-1 ${testResult.success ? "text-green-600" : "text-red-600"}`}>
{testResult.success ? (
<CheckCircle className="h-3.5 w-3.5" />
) : (
<AlertCircle className="h-3.5 w-3.5" />
)}
{testResult.message}
</div>
)}
</div>
)}
</div>
{saveError && (
<div className="px-6 pb-2 text-sm text-red-600">{saveError}</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowDialog(false)}>
<X className="h-4 w-4 mr-1" />
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 메일 삭제 확인 */}
<AlertDialog open={!!pendingDeleteMail} onOpenChange={(open) => { if (!open) setPendingDeleteMail(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> ?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteMail} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 계정 삭제 확인 */}
<AlertDialog open={!!pendingDeleteAccount} onOpenChange={(open) => { if (!open) setPendingDeleteAccount(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>"{pendingDeleteAccount?.displayName}" ?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteAccount} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}