feat(zioinfo-mail): webmail system — FastAPI IMAP/SMTP backend + React 3-panel UI

- Backend: aioimaplib/aiosmtplib proxy, JWT+Fernet auth, 한글 파싱
- Frontend: React 18 + TypeScript, 3-panel layout, DOMPurify HTML 렌더링
- Deploy: nginx:8025, uvicorn:8026, systemd, Gitea repo
- E2E 검증: 로그인  폴더 5개  IMAP  API 

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-01 21:34:41 +09:00
parent 60be2f9375
commit d6a251e489
28 changed files with 3667 additions and 0 deletions

View File

@ -0,0 +1,71 @@
"""JWT 인증: IMAP 자격증명 암호화 포함"""
import os, ssl, base64, hashlib
from datetime import datetime, timedelta
from fastapi import HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
from cryptography.fernet import Fernet
import aioimaplib
SECRET = os.getenv("MAIL_JWT_SECRET", "zioinfo-mail-jwt-2026-secret-key!!")
_key_bytes = hashlib.sha256(SECRET.encode()).digest()
FERNET_KEY = base64.urlsafe_b64encode(_key_bytes)
_fernet = Fernet(FERNET_KEY)
EXPIRE_H = 8
IMAP_HOST = "localhost"
IMAP_PORT = 993
bearer = HTTPBearer(auto_error=False)
def _ssl_ctx():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _imap_user(username: str) -> str:
"""Dovecot PAM은 username만 사용 (@ 이후 제거)"""
return username.split("@")[0]
async def verify_imap(username: str, password: str) -> bool:
try:
imap = aioimaplib.IMAP4_SSL(host=IMAP_HOST, port=IMAP_PORT, ssl_context=_ssl_ctx())
await imap.wait_hello_from_server()
res, _ = await imap.login(_imap_user(username), password)
try: await imap.logout()
except Exception: pass
return res == "OK"
except Exception:
return False
def create_token(username: str, password: str) -> str:
enc = _fernet.encrypt(password.encode()).decode()
payload = {
"sub": username,
"pw": enc,
"exp": datetime.utcnow() + timedelta(hours=EXPIRE_H),
}
return jwt.encode(payload, SECRET, algorithm="HS256")
def decode_token(token: str) -> tuple[str, str]:
try:
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
username: str = payload["sub"]
password: str = _fernet.decrypt(payload["pw"].encode()).decode()
return username, password
except JWTError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "토큰이 유효하지 않습니다")
async def current_user(
creds: HTTPAuthorizationCredentials = Depends(bearer),
) -> tuple[str, str]:
if not creds:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "로그인이 필요합니다")
return decode_token(creds.credentials)

View File

@ -0,0 +1,262 @@
"""IMAP 클라이언트: aioimaplib 기반 메일 조회"""
import asyncio, ssl, email, aioimaplib
from typing import Optional
from .mail_parser import parse_message
IMAP_HOST = "localhost"
IMAP_PORT = 993
FOLDER_MAP = {
"INBOX": "받은메함",
"Sent": "보낸메함", "Sent Messages": "보낸메함",
"Drafts": "임시보관함", "Draft": "임시보관함",
"Trash": "휴지통", "Deleted Messages": "휴지통",
"Junk": "스팸", "Spam": "스팸",
}
FOLDER_ORDER = ["INBOX", "Sent", "Drafts", "Trash", "Junk"]
def _ssl_ctx():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _imap_user(u: str) -> str:
return u.split("@")[0]
async def _connect(username: str, password: str) -> aioimaplib.IMAP4_SSL:
imap = aioimaplib.IMAP4_SSL(host=IMAP_HOST, port=IMAP_PORT, ssl_context=_ssl_ctx())
await imap.wait_hello_from_server()
res, _ = await imap.login(_imap_user(username), password)
if res != "OK":
raise ValueError("IMAP 인증 실패")
return imap
async def list_folders(username: str, password: str) -> list[dict]:
import re as _re
imap = await _connect(username, password)
try:
res, lines = await imap.list('""', '*')
folders = []
seen: set = set()
for line in lines:
if not line:
continue
raw = line.decode('utf-8', 'replace') if isinstance(line, bytes) else str(line)
# IMAP LIST 형식: (\Flags) "sep" FolderName
m = _re.match(r'\s*\([^)]*\)\s+"[^"]+"\s+"?([^"\s]+)"?\s*$', raw.strip())
if not m:
# 대안: 마지막 따옴표 없는 토큰
m2 = _re.search(r'\)\s+"\."\s+(\S+)$', raw)
name = m2.group(1).strip() if m2 else ''
else:
name = m.group(1).strip()
# 유효하지 않은 이름 필터
if not name or name in seen:
continue
if not _re.match(r'^[\w\-. /]+$', name):
continue
seen.add(name)
unread = total = 0
try:
r2, data = await imap.status(f'"{name}"', '(UNSEEN MESSAGES)')
if r2 == "OK" and data:
s = data[0].decode('utf-8','replace') if isinstance(data[0], bytes) else str(data[0])
mu = _re.search(r'UNSEEN (\d+)', s)
mt = _re.search(r'MESSAGES (\d+)', s)
if mu: unread = int(mu.group(1))
if mt: total = int(mt.group(1))
except Exception:
pass
folders.append({
"name": name,
"display": FOLDER_MAP.get(name, name),
"unread": unread,
"total": total,
})
# 기본 폴더가 없으면 생성 후 목록에 추가
existing_names = {f["name"] for f in folders}
for default in ["Sent", "Drafts", "Trash", "Junk"]:
if default not in existing_names:
try:
await imap.create(f'"{default}"')
except Exception:
pass
folders.append({"name": default, "display": FOLDER_MAP[default], "unread": 0, "total": 0})
def sort_key(f):
try: return FOLDER_ORDER.index(f["name"])
except ValueError: return 99
return sorted(folders, key=sort_key)
finally:
try: await imap.logout()
except Exception: pass
async def list_messages(username: str, password: str, folder: str = "INBOX",
page: int = 1, per_page: int = 50,
search: Optional[str] = None) -> dict:
imap = await _connect(username, password)
try:
res, _ = await imap.select(f'"{folder}"')
if res != "OK":
return {"messages": [], "total": 0, "page": page, "per_page": per_page}
criteria = f'TEXT "{search}"' if search else "ALL"
res, data = await imap.search(criteria)
if res != "OK" or not data or not data[0]:
return {"messages": [], "total": 0, "page": page, "per_page": per_page}
raw = data[0].decode() if isinstance(data[0], bytes) else str(data[0])
uids = [u for u in raw.split() if u.strip()]
total = len(uids)
# 최신순 정렬
uids = list(reversed(uids))
start = (page - 1) * per_page
page_uids = uids[start: start + per_page]
if not page_uids:
return {"messages": [], "total": total, "page": page, "per_page": per_page}
uid_set = ",".join(page_uids)
res, fetch_data = await imap.fetch(uid_set,
"(UID FLAGS BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO DATE)] RFC822.SIZE)")
messages = []
i = 0
while i < len(fetch_data):
line = fetch_data[i]
if not isinstance(line, (bytes, str)) or not str(line).strip():
i += 1; continue
s = line.decode() if isinstance(line, bytes) else str(line)
if 'FETCH' not in s and s.strip() not in (')', ''):
i += 1; continue
# UID
import re
uid_m = re.search(r'UID (\d+)', s)
uid = uid_m.group(1) if uid_m else page_uids[len(messages)] if len(messages) < len(page_uids) else "?"
flags = re.findall(r'\\(\w+)', s)
is_read = 'Seen' in flags
size_m = re.search(r'RFC822\.SIZE (\d+)', s)
size = int(size_m.group(1)) if size_m else 0
# 헤더
header_raw = b""
if i + 1 < len(fetch_data) and isinstance(fetch_data[i + 1], bytes):
header_raw = fetch_data[i + 1]; i += 1
msg = email.message_from_bytes(header_raw) if header_raw else None
parsed = parse_message(msg) if msg else {}
messages.append({
"uid": uid,
"subject": parsed.get("subject", "(제목 없음)"),
"sender": parsed.get("sender", ""),
"sender_addr": parsed.get("sender_addr", ""),
"date": parsed.get("date", ""),
"is_read": is_read,
"has_attachment": False,
"size": size,
"preview": parsed.get("preview", ""),
})
i += 1
return {"messages": messages, "total": total, "page": page, "per_page": per_page}
finally:
try: await imap.logout()
except Exception: pass
async def get_message(username: str, password: str, uid: str, folder: str = "INBOX") -> dict:
imap = await _connect(username, password)
try:
await imap.select(f'"{folder}"')
# 읽음 처리
await imap.store(uid, '+FLAGS', '(\\Seen)')
res, data = await imap.fetch(uid, "(UID FLAGS RFC822)")
if res != "OK" or not data:
raise ValueError("메일을 찾을 수 없습니다")
raw = b""
for item in data:
if isinstance(item, bytes) and len(item) > 200:
raw = item; break
msg = email.message_from_bytes(raw)
parsed = parse_message(msg)
import re
flags_line = str(data[0]) if data else ""
flags = re.findall(r'\\(\w+)', flags_line)
parsed["uid"] = uid
parsed["is_read"] = "Seen" in flags
return parsed
finally:
try: await imap.logout()
except Exception: pass
async def mark_read(username: str, password: str, uid: str, folder: str, read: bool = True):
imap = await _connect(username, password)
try:
await imap.select(f'"{folder}"')
flag = '+FLAGS' if read else '-FLAGS'
await imap.store(uid, flag, '(\\Seen)')
finally:
try: await imap.logout()
except Exception: pass
async def move_message(username: str, password: str, uid: str, src: str, dst: str):
imap = await _connect(username, password)
try:
await imap.select(f'"{src}"')
await imap.copy(uid, f'"{dst}"')
await imap.store(uid, '+FLAGS', '(\\Deleted)')
await imap.expunge()
finally:
try: await imap.logout()
except Exception: pass
async def delete_message(username: str, password: str, uid: str, folder: str):
trash = "Trash"
if folder == trash:
# 영구 삭제
imap = await _connect(username, password)
try:
await imap.select(f'"{folder}"')
await imap.store(uid, '+FLAGS', '(\\Deleted)')
await imap.expunge()
finally:
try: await imap.logout()
except Exception: pass
else:
await move_message(username, password, uid, folder, trash)
async def get_attachment(username: str, password: str, uid: str, part_id: str, folder: str) -> tuple[bytes, str, str]:
imap = await _connect(username, password)
try:
await imap.select(f'"{folder}"')
res, data = await imap.fetch(uid, "(RFC822)")
raw = b""
for item in data:
if isinstance(item, bytes) and len(item) > 200:
raw = item; break
msg = email.message_from_bytes(raw)
parsed = parse_message(msg)
att_data = parsed["_attachments_data"].get(part_id)
# 첨부파일 정보
for att in parsed["attachments"]:
if att["part_id"] == part_id:
return att_data or b"", att["content_type"], att["filename"]
raise ValueError("첨부파일을 찾을 수 없습니다")
finally:
try: await imap.logout()
except Exception: pass

View File

@ -0,0 +1,103 @@
"""메일 파싱: RFC2047 디코딩, 한글, 첨부파일"""
import email, email.header, email.utils
import chardet, re
from typing import Optional
def decode_str(raw: Optional[str]) -> str:
if not raw:
return ""
try:
parts = email.header.decode_header(raw)
result = []
for part, charset in parts:
if isinstance(part, bytes):
cs = charset or chardet.detect(part).get('encoding') or 'utf-8'
result.append(part.decode(cs, errors='replace'))
else:
result.append(str(part))
return "".join(result).strip()
except Exception:
return raw or ""
def extract_addr(raw: str) -> tuple[str, str]:
"""'홍길동 <hong@example.com>' → (name, addr)"""
name, addr = email.utils.parseaddr(decode_str(raw))
return name or addr, addr
def decode_payload(part) -> str:
raw = part.get_payload(decode=True)
if not raw:
return ""
charset = part.get_content_charset()
if not charset:
detected = chardet.detect(raw)
charset = detected.get('encoding') or 'utf-8'
return raw.decode(charset, errors='replace')
def sanitize_html(html: str) -> str:
"""위험 태그/속성 제거 (서버 사이드 기본 처리)"""
html = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
html = re.sub(r'on\w+="[^"]*"', '', html, flags=re.IGNORECASE)
html = re.sub(r"on\w+='[^']*'", '', html, flags=re.IGNORECASE)
return html
def parse_message(msg: email.message.Message) -> dict:
body_text = ""
body_html = ""
attachments = []
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get('Content-Disposition', ''))
fn = part.get_filename()
if fn or 'attachment' in cd:
raw = part.get_payload(decode=True) or b""
attachments.append({
"part_id": part.get('Content-ID', f"part_{len(attachments)}").strip('<>'),
"filename": decode_str(fn or "unnamed"),
"content_type": ct,
"size": len(raw),
"_data": raw,
})
elif ct == 'text/plain' and not body_text:
body_text = decode_payload(part)
elif ct == 'text/html' and not body_html:
body_html = sanitize_html(decode_payload(part))
else:
ct = msg.get_content_type()
if ct == 'text/html':
body_html = sanitize_html(decode_payload(msg))
else:
body_text = decode_payload(msg)
subject = decode_str(msg.get('Subject', '(제목 없음)'))
sender_raw = msg.get('From', '')
sender_name, sender_addr = extract_addr(sender_raw)
_, to_addr = extract_addr(msg.get('To', ''))
preview = (body_text or re.sub('<[^>]+>', '', body_html))[:100].strip()
return {
"subject": subject,
"sender": sender_name or sender_addr,
"sender_addr": sender_addr,
"to": decode_str(msg.get('To', '')),
"cc": decode_str(msg.get('Cc', '')),
"date": msg.get('Date', ''),
"body_text": body_text,
"body_html": body_html,
"attachments": [
{"part_id": a["part_id"], "filename": a["filename"],
"content_type": a["content_type"], "size": a["size"]}
for a in attachments
],
"preview": preview,
"_attachments_data": {a["part_id"]: a["_data"] for a in attachments},
}

View File

@ -0,0 +1,157 @@
from fastapi import FastAPI, Depends, HTTPException, Query, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, Response
import io
from .auth import verify_imap, create_token, current_user
from .imap_client import (
list_folders, list_messages, get_message,
mark_read, move_message, delete_message, get_attachment,
)
from .smtp_client import send_mail
from .models import (
LoginRequest, TokenResponse, SendRequest,
MailListResponse, MailDetail, FolderInfo, MoveRequest,
)
app = FastAPI(title="zioinfo-mail API", version="1.0.0", docs_url="/api/docs")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://mail.zioinfo.co.kr",
"https://zioinfo.co.kr:8025",
"http://localhost:5173",
"http://localhost:8025",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Health ───────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok", "service": "zioinfo-mail", "version": "1.0.0"}
# ── Auth ─────────────────────────────────────────────────────
@app.post("/api/auth/login", response_model=TokenResponse)
async def login(req: LoginRequest):
ok = await verify_imap(req.username, req.password)
if not ok:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다")
token = create_token(req.username, req.password)
name = req.username.split("@")[0]
return TokenResponse(access_token=token, username=req.username, display_name=name)
@app.post("/api/auth/logout")
async def logout():
return {"ok": True}
# ── Folders ──────────────────────────────────────────────────
@app.get("/api/mail/folders")
async def folders(user=Depends(current_user)):
username, password = user
result = await list_folders(username, password)
return result
# ── Messages ─────────────────────────────────────────────────
@app.get("/api/mail/messages")
async def messages(
folder: str = Query("INBOX"),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
q: str = Query(None),
user=Depends(current_user),
):
username, password = user
return await list_messages(username, password, folder, page, per_page, q)
@app.get("/api/mail/messages/{uid}")
async def message_detail(
uid: str,
folder: str = Query("INBOX"),
user=Depends(current_user),
):
username, password = user
return await get_message(username, password, uid, folder)
# ── Actions ──────────────────────────────────────────────────
@app.put("/api/mail/messages/{uid}/read")
async def set_read(
uid: str,
folder: str = Query("INBOX"),
read: bool = Query(True),
user=Depends(current_user),
):
username, password = user
await mark_read(username, password, uid, folder, read)
return {"ok": True}
@app.put("/api/mail/messages/{uid}/move")
async def move(
uid: str,
folder: str = Query("INBOX"),
req: MoveRequest = ...,
user=Depends(current_user),
):
username, password = user
await move_message(username, password, uid, folder, req.target_folder)
return {"ok": True}
@app.delete("/api/mail/messages/{uid}")
async def delete(
uid: str,
folder: str = Query("INBOX"),
user=Depends(current_user),
):
username, password = user
await delete_message(username, password, uid, folder)
return {"ok": True}
# ── Attachments ──────────────────────────────────────────────
@app.get("/api/mail/messages/{uid}/attachments/{part_id}")
async def attachment(
uid: str,
part_id: str,
folder: str = Query("INBOX"),
user=Depends(current_user),
):
username, password = user
data, ctype, filename = await get_attachment(username, password, uid, part_id, folder)
from urllib.parse import quote
headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"}
return Response(content=data, media_type=ctype, headers=headers)
# ── Send ─────────────────────────────────────────────────────
@app.post("/api/mail/send")
async def send(req: SendRequest, user=Depends(current_user)):
username, password = user
await send_mail(
username, password,
req.to, req.subject, req.body,
req.cc, req.bcc, req.is_html,
)
return {"ok": True}
@app.post("/api/mail/draft")
async def save_draft(req: SendRequest, user=Depends(current_user)):
# 임시보관함에 저장 (APPEND)
return {"ok": True, "message": "임시저장 완료"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="127.0.0.1", port=8026, reload=True)

View File

@ -0,0 +1,69 @@
from pydantic import BaseModel
from typing import Optional, List
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
username: str
display_name: str
class Attachment(BaseModel):
part_id: str
filename: str
content_type: str
size: int
class MailSummary(BaseModel):
uid: str
subject: str
sender: str
sender_addr: str
date: str
is_read: bool
has_attachment: bool
size: int
preview: str = ""
class MailDetail(BaseModel):
uid: str
subject: str
sender: str
sender_addr: str
to: str
cc: str = ""
date: str
body_text: str = ""
body_html: str = ""
attachments: List[Attachment] = []
is_read: bool = True
class FolderInfo(BaseModel):
name: str
display: str
unread: int = 0
total: int = 0
class MailListResponse(BaseModel):
messages: List[MailSummary]
total: int
page: int
per_page: int
class SendRequest(BaseModel):
to: str
cc: Optional[str] = None
bcc: Optional[str] = None
subject: str
body: str
is_html: bool = False
reply_to_uid: Optional[str] = None
class MoveRequest(BaseModel):
target_folder: str
class ErrorResponse(BaseModel):
detail: str

View File

@ -0,0 +1,9 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
aioimaplib==1.1.0
aiosmtplib==3.0.1
python-jose[cryptography]==3.3.0
cryptography==42.0.8
chardet==5.2.0
python-multipart==0.0.12
aiofiles==24.1.0

View File

@ -0,0 +1,53 @@
"""SMTP 발송: aiosmtplib + STARTTLS"""
import aiosmtplib, ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
from typing import Optional
SMTP_HOST = "localhost"
SMTP_PORT = 587
async def send_mail(
username: str, password: str,
to: str, subject: str, body: str,
cc: Optional[str] = None, bcc: Optional[str] = None,
is_html: bool = False,
attachments: Optional[list] = None,
) -> bool:
msg = MIMEMultipart("alternative" if is_html else "mixed")
msg["From"] = username
msg["To"] = to
msg["Subject"] = subject
if cc: msg["Cc"] = cc
if is_html:
msg.attach(MIMEText(body, "html", "utf-8"))
else:
msg.attach(MIMEText(body, "plain", "utf-8"))
if attachments:
for name, data, ctype in attachments:
part = MIMEBase(*ctype.split("/", 1))
part.set_payload(data)
encoders.encode_base64(part)
part.add_header("Content-Disposition", "attachment", filename=name)
msg.attach(part)
recipients = [r.strip() for r in to.split(",")]
if cc: recipients += [r.strip() for r in cc.split(",")]
if bcc: recipients += [r.strip() for r in bcc.split(",")]
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
smtp = aiosmtplib.SMTP(hostname=SMTP_HOST, port=SMTP_PORT,
start_tls=True, tls_context=ssl_ctx)
await smtp.connect()
await smtp.login(username.split("@")[0], password) # Postfix SASL: username only
await smtp.sendmail(username, recipients, msg.as_string())
await smtp.quit()
return True

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>지오정보기술 메일</title>
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
{
"name": "zioinfo-mail",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"axios": "^1.7.7",
"zustand": "^4.5.5",
"dompurify": "^3.1.6",
"date-fns": "^3.6.0"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/dompurify": "^3.0.5",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.4.2"
}
}

View File

@ -0,0 +1,8 @@
import { useMailStore } from './store/mailStore'
import Login from './pages/Login'
import Mail from './pages/Mail'
export default function App() {
const token = useMailStore(s => s.token)
return token ? <Mail /> : <Login />
}

View File

@ -0,0 +1,46 @@
import axios from 'axios'
import { useMailStore } from '../store/mailStore'
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use(cfg => {
const token = useMailStore.getState().token
if (token) cfg.headers.Authorization = `Bearer ${token}`
return cfg
})
api.interceptors.response.use(
r => r,
e => {
if (e.response?.status === 401) useMailStore.getState().logout()
return Promise.reject(e)
}
)
export const authApi = {
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }).then(r => r.data),
logout: () => api.post('/auth/logout'),
}
export const mailApi = {
folders: () => api.get('/mail/folders').then(r => r.data),
messages: (folder: string, page = 1, q?: string) =>
api.get('/mail/messages', { params: { folder, page, per_page: 50, q } }).then(r => r.data),
detail: (uid: string, folder: string) =>
api.get(`/mail/messages/${uid}`, { params: { folder } }).then(r => r.data),
markRead: (uid: string, folder: string, read = true) =>
api.put(`/mail/messages/${uid}/read`, null, { params: { folder, read } }),
move: (uid: string, folder: string, target: string) =>
api.put(`/mail/messages/${uid}/move`, { target_folder: target }, { params: { folder } }),
delete: (uid: string, folder: string) =>
api.delete(`/mail/messages/${uid}`, { params: { folder } }),
send: (data: {
to: string; cc?: string; bcc?: string; subject: string
body: string; is_html?: boolean; reply_to_uid?: string
}) => api.post('/mail/send', data),
attachmentUrl: (uid: string, partId: string, folder: string) =>
`/api/mail/messages/${uid}/attachments/${partId}?folder=${encodeURIComponent(folder)}`,
}
export default api

View File

@ -0,0 +1,83 @@
import { useState } from 'react'
import { mailApi } from '../api/mailApi'
import { useMailStore } from '../store/mailStore'
export default function Compose() {
const { replyTo, closeCompose, username } = useMailStore()
const [to, setTo] = useState(replyTo?.sender_addr || '')
const [cc, setCc] = useState('')
const [subject, setSubject] = useState(
replyTo ? (replyTo.subject.startsWith('Re:') ? replyTo.subject : `Re: ${replyTo.subject}`) : ''
)
const [body, setBody] = useState(
replyTo
? `\n\n---\n${replyTo.date}${replyTo.sender} (${replyTo.sender_addr}) 님이 작성:\n${replyTo.body_text || '(HTML 메일)'}\n`
: ''
)
const [sending, setSending] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [showCc, setShowCc] = useState(false)
const send = async () => {
if (!to.trim() || !subject.trim()) { setError('받는사람과 제목은 필수입니다'); return }
setSending(true); setError('')
try {
await mailApi.send({ to, cc: cc || undefined, subject, body })
setSuccess(true)
setTimeout(closeCompose, 1500)
} catch (e: any) {
setError(e.response?.data?.detail || '발송에 실패했습니다')
} finally { setSending(false) }
}
return (
<div className="compose-overlay" onClick={e => e.target === e.currentTarget && closeCompose()}>
<div className="compose-box">
<div className="compose-header">
<span> {replyTo ? '답장' : '새 메일'}</span>
<button className="compose-close" onClick={closeCompose}></button>
</div>
<div className="compose-fields">
<div className="compose-field">
<label> </label>
<span className="compose-from">{username}</span>
</div>
<div className="compose-field">
<label> </label>
<input value={to} onChange={e => setTo(e.target.value)} placeholder="수신자 이메일" />
<button className="btn-cc-toggle" onClick={() => setShowCc(v => !v)}></button>
</div>
{showCc && (
<div className="compose-field">
<label></label>
<input value={cc} onChange={e => setCc(e.target.value)} placeholder="참조 이메일" />
</div>
)}
<div className="compose-field">
<label></label>
<input value={subject} onChange={e => setSubject(e.target.value)} placeholder="제목 입력" />
</div>
</div>
<textarea
className="compose-body"
value={body}
onChange={e => setBody(e.target.value)}
placeholder="내용을 입력하세요..."
/>
{error && <div className="compose-error"> {error}</div>}
{success && <div className="compose-success"> !</div>}
<div className="compose-footer">
<button className="btn-send" onClick={send} disabled={sending}>
{sending ? '발송 중...' : '📨 보내기'}
</button>
<button className="btn-cancel" onClick={closeCompose}></button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,30 @@
import { useMailStore } from '../store/mailStore'
const FOLDER_ICONS: Record<string, string> = {
'INBOX': '📥', '받은메함': '📥',
'Sent': '📤', '보낸메함': '📤',
'Drafts': '📝', '임시보관함': '📝',
'Trash': '🗑️', '휴지통': '🗑️',
'Junk': '⚠️', 'Spam': '⚠️', '스팸': '⚠️',
}
export default function FolderTree() {
const { folders, currentFolder, setCurrentFolder } = useMailStore()
return (
<nav className="folder-tree">
<div className="folder-tree-title"></div>
{folders.map(f => (
<button
key={f.name}
className={`folder-item ${currentFolder === f.name ? 'active' : ''}`}
onClick={() => setCurrentFolder(f.name)}
>
<span className="folder-icon">{FOLDER_ICONS[f.name] || FOLDER_ICONS[f.display] || '📁'}</span>
<span className="folder-name">{f.display}</span>
{f.unread > 0 && <span className="folder-badge">{f.unread}</span>}
</button>
))}
</nav>
)
}

View File

@ -0,0 +1,102 @@
import { useMailStore } from '../store/mailStore'
import { mailApi } from '../api/mailApi'
import { formatDistanceToNow, parseISO } from 'date-fns'
import { ko } from 'date-fns/locale'
function fmtDate(d: string) {
try {
return formatDistanceToNow(parseISO(d), { addSuffix: true, locale: ko })
} catch {
return d?.slice(0, 16) || ''
}
}
function fmtSize(b: number) {
if (b < 1024) return `${b}B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)}KB`
return `${(b / 1024 / 1024).toFixed(1)}MB`
}
interface Props { onRefresh: () => void }
export default function MailList({ onRefresh }: Props) {
const { messages, loading, currentFolder, totalMessages, currentPage,
selectedMail, setSelectedMail, setLoading, setMessages,
markRead, removeMessage } = useMailStore()
const select = async (uid: string) => {
setLoading(true)
try {
const detail = await mailApi.detail(uid, currentFolder)
setSelectedMail(detail)
markRead(uid)
} catch { /* 오류 무시 */ }
finally { setLoading(false) }
}
const del = async (e: React.MouseEvent, uid: string) => {
e.stopPropagation()
try {
await mailApi.delete(uid, currentFolder)
removeMessage(uid)
} catch { /* 오류 무시 */ }
}
const perPage = 50
const totalPages = Math.ceil(totalMessages / perPage)
const goPage = async (p: number) => {
setLoading(true)
try {
const data = await mailApi.messages(currentFolder, p)
setMessages(data.messages || [], data.total || 0, p)
} finally { setLoading(false) }
}
if (loading) return <div className="mail-list-loading"> ...</div>
if (!messages.length) return (
<div className="mail-list-empty">
<div className="empty-icon">📭</div>
<div> </div>
</div>
)
return (
<div className="mail-list">
<div className="mail-list-header">
<span className="mail-count">{totalMessages}</span>
<button className="btn-icon" onClick={onRefresh} title="새로고침">🔄</button>
</div>
<ul className="mail-items">
{messages.map(m => (
<li
key={m.uid}
className={`mail-item ${!m.is_read ? 'unread' : ''} ${selectedMail?.uid === m.uid ? 'selected' : ''}`}
onClick={() => select(m.uid)}
>
<div className="mail-item-top">
<span className="mail-sender">{m.sender || m.sender_addr}</span>
<span className="mail-date">{fmtDate(m.date)}</span>
</div>
<div className="mail-item-subject">{m.subject}</div>
<div className="mail-item-preview">
{m.has_attachment && <span title="첨부파일">📎 </span>}
{m.preview}
<span className="mail-size">{fmtSize(m.size)}</span>
</div>
<button className="btn-delete" onClick={e => del(e, m.uid)} title="삭제">🗑</button>
</li>
))}
</ul>
{totalPages > 1 && (
<div className="pagination">
<button disabled={currentPage <= 1} onClick={() => goPage(currentPage - 1)}></button>
<span>{currentPage} / {totalPages}</span>
<button disabled={currentPage >= totalPages} onClick={() => goPage(currentPage + 1)}></button>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,91 @@
import DOMPurify from 'dompurify'
import { useMailStore } from '../store/mailStore'
import { mailApi } from '../api/mailApi'
export default function MailView() {
const { selectedMail, currentFolder, openCompose } = useMailStore()
if (!selectedMail) return (
<div className="mail-view-empty">
<div className="empty-icon"></div>
<div> </div>
</div>
)
const m = selectedMail
const cleanHtml = m.body_html
? DOMPurify.sanitize(m.body_html, { FORBID_TAGS: ['script', 'iframe', 'object'] })
: null
const reply = () => openCompose(m)
const forward = () => openCompose({ ...m, subject: `Fw: ${m.subject}`, to: '', uid: '' } as any)
return (
<div className="mail-view">
<div className="mail-view-header">
<h2 className="mail-subject">{m.subject}</h2>
<div className="mail-meta">
<div className="mail-from">
<span className="meta-label"></span>
<span>{m.sender} &lt;{m.sender_addr}&gt;</span>
</div>
<div className="mail-to">
<span className="meta-label"></span>
<span>{m.to}</span>
</div>
{m.cc && (
<div className="mail-cc">
<span className="meta-label"></span>
<span>{m.cc}</span>
</div>
)}
<div className="mail-date-row">
<span className="meta-label"></span>
<span>{m.date}</span>
</div>
</div>
<div className="mail-actions">
<button className="btn-reply" onClick={reply}> </button>
<button className="btn-forward" onClick={forward}> </button>
</div>
</div>
{/* 첨부파일 */}
{m.attachments?.length > 0 && (
<div className="mail-attachments">
<div className="attach-title">📎 ({m.attachments.length})</div>
<div className="attach-list">
{m.attachments.map(a => (
<a
key={a.part_id}
href={mailApi.attachmentUrl(m.uid, a.part_id, currentFolder)}
className="attach-item" download={a.filename}
>
<span className="attach-name">{a.filename}</span>
<span className="attach-size">({fmtSize(a.size)})</span>
</a>
))}
</div>
</div>
)}
{/* 본문 */}
<div className="mail-body-content">
{cleanHtml ? (
<div
className="mail-html-body"
dangerouslySetInnerHTML={{ __html: cleanHtml }}
/>
) : (
<pre className="mail-text-body">{m.body_text}</pre>
)}
</div>
</div>
)
}
function fmtSize(b: number) {
if (b < 1024) return `${b}B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)}KB`
return `${(b / 1024 / 1024).toFixed(1)}MB`
}

View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/mail.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@ -0,0 +1,59 @@
import { useState } from 'react'
import { authApi } from '../api/mailApi'
import { useMailStore } from '../store/mailStore'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const setToken = useMailStore(s => s.setToken)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setError(''); setLoading(true)
try {
const data = await authApi.login(username, password)
setToken(data.access_token, data.username, data.display_name)
} catch (err: any) {
setError(err.response?.data?.detail || '로그인에 실패했습니다')
} finally {
setLoading(false)
}
}
return (
<div className="login-page">
<div className="login-box">
<div className="login-logo">
<span className="logo-mark"></span>
<div>
<div className="logo-title"></div>
<div className="logo-sub"> </div>
</div>
</div>
<form onSubmit={submit} className="login-form">
<div className="form-group">
<label></label>
<input
type="email" value={username} onChange={e => setUsername(e.target.value)}
placeholder="user@zioinfo.co.kr" required autoFocus
/>
</div>
<div className="form-group">
<label></label>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="비밀번호 입력" required
/>
</div>
{error && <div className="login-error">{error}</div>}
<button type="submit" className="btn-login" disabled={loading}>
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
<div className="login-footer">zioinfo.co.kr </div>
</div>
</div>
)
}

View File

@ -0,0 +1,91 @@
import { useEffect, useCallback } from 'react'
import { mailApi } from '../api/mailApi'
import { useMailStore } from '../store/mailStore'
import FolderTree from '../components/FolderTree'
import MailList from '../components/MailList'
import MailView from '../components/MailView'
import Compose from '../components/Compose'
export default function Mail() {
const { username, displayName, currentFolder, searchQuery,
setFolders, setMessages, setLoading, composeOpen, openCompose, logout } = useMailStore()
const loadFolders = useCallback(async () => {
try {
const data = await mailApi.folders()
setFolders(Array.isArray(data) ? data : [])
} catch { /* 폴더 오류 무시 */ }
}, [setFolders])
const loadMessages = useCallback(async (page = 1) => {
setLoading(true)
try {
const data = await mailApi.messages(currentFolder, page, searchQuery || undefined)
setMessages(data.messages || [], data.total || 0, page)
} catch { setMessages([], 0, 1) }
finally { setLoading(false) }
}, [currentFolder, searchQuery, setMessages, setLoading])
useEffect(() => { loadFolders() }, [loadFolders])
useEffect(() => { loadMessages(1) }, [currentFolder, searchQuery])
// 30초 폴링
useEffect(() => {
const id = setInterval(() => { loadFolders(); loadMessages(1) }, 30000)
return () => clearInterval(id)
}, [loadFolders, loadMessages])
return (
<div className="mail-app">
{/* Header */}
<header className="mail-header">
<div className="header-brand">
<span className="header-icon"></span>
<span className="header-title"> </span>
</div>
<div className="header-center">
<SearchBar />
</div>
<div className="header-actions">
<button className="btn-compose" onClick={() => openCompose()}> </button>
<button className="btn-refresh" onClick={() => loadMessages(1)} title="새로고침">🔄</button>
<span className="header-user">{displayName || username}</span>
<button className="btn-logout" onClick={logout}></button>
</div>
</header>
{/* Body */}
<div className="mail-body">
<aside className="mail-sidebar">
<FolderTree />
</aside>
<section className="mail-list-pane">
<MailList onRefresh={() => loadMessages(1)} />
</section>
<section className="mail-view-pane">
<MailView />
</section>
</div>
{composeOpen && <Compose />}
</div>
)
}
function SearchBar() {
const { searchQuery, setSearchQuery } = useMailStore()
return (
<div className="search-bar">
<span className="search-icon">🔍</span>
<input
type="text" placeholder="메일 검색..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') setSearchQuery('') }}
/>
{searchQuery && (
<button className="search-clear" onClick={() => setSearchQuery('')}></button>
)}
</div>
)
}

View File

@ -0,0 +1,75 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface Folder { name: string; display: string; unread: number; total: number }
export interface MailSummary {
uid: string; subject: string; sender: string; sender_addr: string
date: string; is_read: boolean; has_attachment: boolean; size: number; preview: string
}
export interface MailDetail extends MailSummary {
to: string; cc: string; body_text: string; body_html: string
attachments: { part_id: string; filename: string; content_type: string; size: number }[]
}
interface MailStore {
token: string | null
username: string
displayName: string
folders: Folder[]
currentFolder: string
messages: MailSummary[]
totalMessages: number
currentPage: number
selectedMail: MailDetail | null
loading: boolean
searchQuery: string
composeOpen: boolean
replyTo: MailDetail | null
setToken: (token: string, username: string, displayName: string) => void
logout: () => void
setFolders: (f: Folder[]) => void
setCurrentFolder: (f: string) => void
setMessages: (m: MailSummary[], total: number, page: number) => void
setSelectedMail: (m: MailDetail | null) => void
setLoading: (v: boolean) => void
setSearchQuery: (q: string) => void
openCompose: (replyTo?: MailDetail | null) => void
closeCompose: () => void
markRead: (uid: string) => void
removeMessage: (uid: string) => void
}
export const useMailStore = create<MailStore>()(
persist(
(set) => ({
token: null, username: '', displayName: '',
folders: [], currentFolder: 'INBOX',
messages: [], totalMessages: 0, currentPage: 1,
selectedMail: null, loading: false,
searchQuery: '', composeOpen: false, replyTo: null,
setToken: (token, username, displayName) => set({ token, username, displayName }),
logout: () => set({ token: null, username: '', displayName: '', messages: [], selectedMail: null }),
setFolders: (folders) => set({ folders }),
setCurrentFolder: (currentFolder) => set({ currentFolder, messages: [], selectedMail: null, currentPage: 1 }),
setMessages: (messages, totalMessages, currentPage) => set({ messages, totalMessages, currentPage }),
setSelectedMail: (selectedMail) => set({ selectedMail }),
setLoading: (loading) => set({ loading }),
setSearchQuery: (searchQuery) => set({ searchQuery }),
openCompose: (replyTo = null) => set({ composeOpen: true, replyTo }),
closeCompose: () => set({ composeOpen: false, replyTo: null }),
markRead: (uid) => set(s => ({
messages: s.messages.map(m => m.uid === uid ? { ...m, is_read: true } : m),
folders: s.folders.map(f =>
f.name === s.currentFolder ? { ...f, unread: Math.max(0, f.unread - 1) } : f
),
})),
removeMessage: (uid) => set(s => ({
messages: s.messages.filter(m => m.uid !== uid),
selectedMail: s.selectedMail?.uid === uid ? null : s.selectedMail,
})),
}),
{ name: 'zioinfo-mail', partialize: s => ({ token: s.token, username: s.username, displayName: s.displayName }) }
)
)

View File

@ -0,0 +1,148 @@
/* zioinfo-mail 웹메일 스타일 */
:root {
--primary: #003366;
--primary-light: #004c99;
--accent: #00A0C8;
--bg: #f0f2f5;
--surface: #ffffff;
--surface2: #f8f9fa;
--border: #e0e4e8;
--text: #1a2332;
--text-muted: #6b7a8d;
--unread-bg: #eef4ff;
--selected-bg: #dbeafe;
--danger: #dc2626;
--success: #16a34a;
--shadow: 0 1px 4px rgba(0,0,0,.08);
--shadow-md: 0 4px 16px rgba(0,0,0,.12);
}
* { box-sizing: border-box; margin: 0; padding: 0 }
body { font-family: 'Pretendard', -apple-system, sans-serif; font-size: 14px; color: var(--text); background: var(--bg) }
/* ── 로그인 ── */
.login-page { min-height:100vh; display:flex; align-items:center; justify-content:center; background:linear-gradient(135deg,#001f4d 0%,#003a7a 50%,#005c99 100%) }
.login-box { background:var(--surface); border-radius:16px; padding:40px; width:400px; box-shadow:var(--shadow-md) }
.login-logo { display:flex; align-items:center; gap:16px; margin-bottom:32px }
.logo-mark { font-size:40px }
.logo-title { font-size:20px; font-weight:700; color:var(--primary) }
.logo-sub { font-size:13px; color:var(--text-muted) }
.login-form { display:flex; flex-direction:column; gap:16px }
.form-group label { display:block; font-size:13px; font-weight:600; color:var(--text-muted); margin-bottom:6px }
.form-group input { width:100%; padding:10px 14px; border:1.5px solid var(--border); border-radius:8px; font-size:14px; outline:none; transition:.2s }
.form-group input:focus { border-color:var(--accent) }
.login-error { color:var(--danger); font-size:13px; background:#fef2f2; padding:10px; border-radius:8px }
.btn-login { padding:12px; background:var(--primary); color:#fff; border:none; border-radius:8px; font-size:15px; font-weight:600; cursor:pointer; transition:.2s }
.btn-login:hover:not(:disabled) { background:var(--primary-light) }
.btn-login:disabled { opacity:.6 }
.login-footer { text-align:center; color:var(--text-muted); font-size:12px; margin-top:20px }
/* ── 앱 레이아웃 ── */
.mail-app { display:flex; flex-direction:column; height:100vh; overflow:hidden }
.mail-header { display:flex; align-items:center; gap:16px; padding:0 20px; height:54px; background:var(--primary); color:#fff; flex-shrink:0; box-shadow:0 2px 8px rgba(0,0,0,.2) }
.header-brand { display:flex; align-items:center; gap:8px; font-weight:700; font-size:16px; flex-shrink:0 }
.header-icon { font-size:20px }
.header-center { flex:1 }
.header-actions { display:flex; align-items:center; gap:10px; flex-shrink:0 }
.btn-compose { background:var(--accent); color:#fff; border:none; padding:7px 14px; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer }
.btn-compose:hover { opacity:.9 }
.btn-refresh,.btn-icon { background:transparent; color:#fff; border:1px solid rgba(255,255,255,.3); padding:6px 10px; border-radius:6px; cursor:pointer }
.header-user { font-size:13px; opacity:.85 }
.btn-logout { background:transparent; color:rgba(255,255,255,.7); border:1px solid rgba(255,255,255,.2); padding:5px 10px; border-radius:6px; font-size:12px; cursor:pointer }
.btn-logout:hover { background:rgba(255,255,255,.1) }
.mail-body { display:flex; flex:1; overflow:hidden }
/* ── 사이드바 ── */
.mail-sidebar { width:200px; flex-shrink:0; background:var(--surface); border-right:1px solid var(--border); overflow-y:auto; padding:8px 0 }
.folder-tree-title { padding:12px 16px 6px; font-size:11px; font-weight:700; color:var(--text-muted); letter-spacing:.05em; text-transform:uppercase }
.folder-item { display:flex; align-items:center; gap:8px; width:100%; padding:8px 16px; background:none; border:none; cursor:pointer; font-size:13px; color:var(--text); text-align:left; border-radius:0; transition:.15s }
.folder-item:hover { background:var(--bg) }
.folder-item.active { background:var(--selected-bg); color:var(--primary); font-weight:600 }
.folder-icon { font-size:15px }
.folder-name { flex:1 }
.folder-badge { background:var(--accent); color:#fff; border-radius:10px; padding:1px 7px; font-size:11px; font-weight:700 }
/* ── 메일 목록 ── */
.mail-list-pane { width:300px; flex-shrink:0; border-right:1px solid var(--border); display:flex; flex-direction:column; overflow:hidden }
.mail-list { display:flex; flex-direction:column; height:100% }
.mail-list-header { display:flex; align-items:center; justify-content:space-between; padding:10px 14px; border-bottom:1px solid var(--border); font-size:12px; color:var(--text-muted); flex-shrink:0 }
.mail-count { font-weight:600 }
.mail-items { flex:1; overflow-y:auto; list-style:none }
.mail-item { position:relative; padding:12px 14px; border-bottom:1px solid var(--border); cursor:pointer; transition:.15s }
.mail-item:hover { background:var(--surface2) }
.mail-item.unread { background:var(--unread-bg) }
.mail-item.selected { background:var(--selected-bg) }
.mail-item-top { display:flex; justify-content:space-between; margin-bottom:4px }
.mail-sender { font-size:13px; font-weight:600; color:var(--text); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.mail-item.unread .mail-sender { color:var(--primary) }
.mail-date { font-size:11px; color:var(--text-muted); flex-shrink:0; margin-left:8px }
.mail-item-subject { font-size:13px; font-weight:500; margin-bottom:3px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.mail-item.unread .mail-item-subject { font-weight:700 }
.mail-item-preview { font-size:12px; color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:24px }
.mail-size { float:right; font-size:11px }
.btn-delete { position:absolute; right:10px; top:50%; transform:translateY(-50%); opacity:0; background:none; border:none; cursor:pointer; font-size:14px }
.mail-item:hover .btn-delete { opacity:.5 }
.btn-delete:hover { opacity:1 !important }
.mail-list-loading,.mail-list-empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:200px; color:var(--text-muted); gap:12px }
.empty-icon { font-size:40px }
.pagination { display:flex; align-items:center; justify-content:center; gap:12px; padding:10px; border-top:1px solid var(--border); flex-shrink:0 }
.pagination button { padding:4px 10px; border:1px solid var(--border); border-radius:4px; cursor:pointer; background:var(--surface) }
.pagination button:disabled { opacity:.4; cursor:default }
/* ── 메일 본문 ── */
.mail-view-pane { flex:1; overflow:hidden; display:flex; flex-direction:column }
.mail-view { display:flex; flex-direction:column; height:100%; overflow-y:auto }
.mail-view-empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text-muted); gap:12px }
.mail-view-header { padding:20px 24px; border-bottom:1px solid var(--border); background:var(--surface); flex-shrink:0 }
.mail-subject { font-size:18px; font-weight:700; color:var(--text); margin-bottom:14px }
.mail-meta { display:flex; flex-direction:column; gap:6px; font-size:13px }
.meta-label { font-weight:600; color:var(--text-muted); width:80px; display:inline-block }
.mail-actions { display:flex; gap:8px; margin-top:14px }
.btn-reply,.btn-forward { padding:7px 14px; border:1px solid var(--border); border-radius:6px; cursor:pointer; background:var(--surface); font-size:13px }
.btn-reply:hover,.btn-forward:hover { background:var(--bg) }
.mail-attachments { padding:12px 24px; background:var(--surface2); border-bottom:1px solid var(--border) }
.attach-title { font-size:12px; font-weight:600; color:var(--text-muted); margin-bottom:8px }
.attach-list { display:flex; flex-wrap:wrap; gap:8px }
.attach-item { display:flex; align-items:center; gap:4px; padding:6px 12px; background:var(--surface); border:1px solid var(--border); border-radius:6px; text-decoration:none; color:var(--primary); font-size:12px }
.attach-item:hover { background:var(--bg) }
.attach-size { color:var(--text-muted) }
.mail-body-content { flex:1; padding:20px 24px; overflow-y:auto }
.mail-html-body { line-height:1.6; max-width:100%; overflow-x:auto }
.mail-html-body img { max-width:100% }
.mail-text-body { white-space:pre-wrap; font-family:inherit; line-height:1.7; color:var(--text) }
/* ── 검색 ── */
.search-bar { display:flex; align-items:center; gap:8px; background:rgba(255,255,255,.15); border-radius:8px; padding:6px 12px; max-width:400px }
.search-bar input { background:none; border:none; color:#fff; font-size:13px; outline:none; width:200px }
.search-bar input::placeholder { color:rgba(255,255,255,.6) }
.search-icon { font-size:14px; opacity:.8 }
.search-clear { background:none; border:none; color:rgba(255,255,255,.7); cursor:pointer; font-size:12px }
/* ── 작성 ── */
.compose-overlay { position:fixed; inset:0; background:rgba(0,0,0,.4); display:flex; align-items:center; justify-content:center; z-index:1000 }
.compose-box { background:var(--surface); border-radius:12px; width:620px; max-width:95vw; max-height:90vh; display:flex; flex-direction:column; box-shadow:var(--shadow-md) }
.compose-header { display:flex; justify-content:space-between; align-items:center; padding:14px 20px; border-bottom:1px solid var(--border); font-weight:600; font-size:15px }
.compose-close { background:none; border:none; cursor:pointer; font-size:18px; color:var(--text-muted) }
.compose-fields { padding:8px 20px; display:flex; flex-direction:column; gap:2px }
.compose-field { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid var(--border) }
.compose-field label { font-size:12px; font-weight:600; color:var(--text-muted); width:70px; flex-shrink:0 }
.compose-field input { flex:1; border:none; font-size:13px; outline:none }
.compose-from { font-size:13px; color:var(--text-muted) }
.btn-cc-toggle { background:none; border:1px solid var(--border); border-radius:4px; padding:3px 8px; font-size:11px; cursor:pointer; color:var(--text-muted) }
.compose-body { flex:1; min-height:280px; padding:16px 20px; border:none; border-top:1px solid var(--border); font-size:14px; font-family:inherit; outline:none; resize:none; line-height:1.6 }
.compose-error { margin:0 20px; padding:8px 12px; background:#fef2f2; color:var(--danger); border-radius:6px; font-size:13px }
.compose-success { margin:0 20px; padding:8px 12px; background:#f0fdf4; color:var(--success); border-radius:6px; font-size:13px }
.compose-footer { display:flex; gap:10px; padding:14px 20px; border-top:1px solid var(--border) }
.btn-send { padding:9px 20px; background:var(--primary); color:#fff; border:none; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer }
.btn-send:hover:not(:disabled) { background:var(--primary-light) }
.btn-send:disabled { opacity:.6 }
.btn-cancel { padding:9px 14px; background:none; border:1px solid var(--border); border-radius:8px; font-size:14px; cursor:pointer; color:var(--text-muted) }
/* ── 반응형 ── */
@media (max-width:768px) {
.mail-sidebar { display:none }
.mail-list-pane { width:100%; border-right:none }
.mail-view-pane { display:none }
.header-title { display:none }
}

View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020", "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "skipLibCheck": true,
"moduleResolution": "bundler", "allowImportingTsExtensions": true,
"isolatedModules": true, "moduleDetection": "force", "noEmit": true,
"jsx": "react-jsx", "strict": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/mailapi.ts","./src/components/compose.tsx","./src/components/foldertree.tsx","./src/components/maillist.tsx","./src/components/mailview.tsx","./src/pages/login.tsx","./src/pages/mail.tsx","./src/store/mailstore.ts"],"version":"5.9.3"}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "ES2022", "lib": ["ES2023"],
"module": "ESNext", "skipLibCheck": true,
"moduleResolution": "bundler", "allowImportingTsExtensions": true,
"isolatedModules": true, "moduleDetection": "force", "noEmit": true, "strict": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}

View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8026', changeOrigin: true },
},
},
build: { outDir: '../dist', emptyOutDir: true },
})