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:
parent
60be2f9375
commit
d6a251e489
0
workspace/zioinfo-mail/backend/__init__.py
Normal file
0
workspace/zioinfo-mail/backend/__init__.py
Normal file
71
workspace/zioinfo-mail/backend/auth.py
Normal file
71
workspace/zioinfo-mail/backend/auth.py
Normal 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)
|
||||||
262
workspace/zioinfo-mail/backend/imap_client.py
Normal file
262
workspace/zioinfo-mail/backend/imap_client.py
Normal 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
|
||||||
103
workspace/zioinfo-mail/backend/mail_parser.py
Normal file
103
workspace/zioinfo-mail/backend/mail_parser.py
Normal 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},
|
||||||
|
}
|
||||||
157
workspace/zioinfo-mail/backend/main.py
Normal file
157
workspace/zioinfo-mail/backend/main.py
Normal 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)
|
||||||
69
workspace/zioinfo-mail/backend/models.py
Normal file
69
workspace/zioinfo-mail/backend/models.py
Normal 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
|
||||||
9
workspace/zioinfo-mail/backend/requirements.txt
Normal file
9
workspace/zioinfo-mail/backend/requirements.txt
Normal 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
|
||||||
53
workspace/zioinfo-mail/backend/smtp_client.py
Normal file
53
workspace/zioinfo-mail/backend/smtp_client.py
Normal 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
|
||||||
15
workspace/zioinfo-mail/frontend/index.html
Normal file
15
workspace/zioinfo-mail/frontend/index.html
Normal 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>
|
||||||
2116
workspace/zioinfo-mail/frontend/package-lock.json
generated
Normal file
2116
workspace/zioinfo-mail/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
workspace/zioinfo-mail/frontend/package.json
Normal file
26
workspace/zioinfo-mail/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
workspace/zioinfo-mail/frontend/src/App.tsx
Normal file
8
workspace/zioinfo-mail/frontend/src/App.tsx
Normal 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 />
|
||||||
|
}
|
||||||
46
workspace/zioinfo-mail/frontend/src/api/mailApi.ts
Normal file
46
workspace/zioinfo-mail/frontend/src/api/mailApi.ts
Normal 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
|
||||||
83
workspace/zioinfo-mail/frontend/src/components/Compose.tsx
Normal file
83
workspace/zioinfo-mail/frontend/src/components/Compose.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
workspace/zioinfo-mail/frontend/src/components/MailList.tsx
Normal file
102
workspace/zioinfo-mail/frontend/src/components/MailList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
workspace/zioinfo-mail/frontend/src/components/MailView.tsx
Normal file
91
workspace/zioinfo-mail/frontend/src/components/MailView.tsx
Normal 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} <{m.sender_addr}></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`
|
||||||
|
}
|
||||||
10
workspace/zioinfo-mail/frontend/src/main.tsx
Normal file
10
workspace/zioinfo-mail/frontend/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
59
workspace/zioinfo-mail/frontend/src/pages/Login.tsx
Normal file
59
workspace/zioinfo-mail/frontend/src/pages/Login.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
workspace/zioinfo-mail/frontend/src/pages/Mail.tsx
Normal file
91
workspace/zioinfo-mail/frontend/src/pages/Mail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
workspace/zioinfo-mail/frontend/src/store/mailStore.ts
Normal file
75
workspace/zioinfo-mail/frontend/src/store/mailStore.ts
Normal 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 }) }
|
||||||
|
)
|
||||||
|
)
|
||||||
148
workspace/zioinfo-mail/frontend/src/styles/mail.css
Normal file
148
workspace/zioinfo-mail/frontend/src/styles/mail.css
Normal 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 }
|
||||||
|
}
|
||||||
12
workspace/zioinfo-mail/frontend/tsconfig.app.json
Normal file
12
workspace/zioinfo-mail/frontend/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
1
workspace/zioinfo-mail/frontend/tsconfig.app.tsbuildinfo
Normal file
1
workspace/zioinfo-mail/frontend/tsconfig.app.tsbuildinfo
Normal 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"}
|
||||||
7
workspace/zioinfo-mail/frontend/tsconfig.json
Normal file
7
workspace/zioinfo-mail/frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
9
workspace/zioinfo-mail/frontend/tsconfig.node.json
Normal file
9
workspace/zioinfo-mail/frontend/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||||
13
workspace/zioinfo-mail/frontend/vite.config.ts
Normal file
13
workspace/zioinfo-mail/frontend/vite.config.ts
Normal 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 },
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user