fix(zioinfo-mail): fix inbox/sent parsing + surrogate encoding + Sent IMAP APPEND

- mail_parser: _safe() surrogate 문자 제거 → JSON 직렬화 오류 수정
- imap_client: aioimaplib → 동기 imaplib으로 전환 (파싱 안정성)
- smtp_client: aiosmtplib → 동기 smtplib으로 전환 + raw bytes 반환
- main.py: 발송 후 append_to_sent() → Sent 폴더 자동 저장
- MailList: Sent 폴더에서 받는사람 표시 (→ info@...)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-01 21:50:47 +09:00
parent d6a251e489
commit 3d5a125b04
5 changed files with 260 additions and 216 deletions

View File

@ -1,7 +1,7 @@
"""IMAP 클라이언트: aioimaplib 기반 메일 조회""" """IMAP 클라이언트: aioimaplib 기반 메일 조회"""
import asyncio, ssl, email, aioimaplib import asyncio, ssl, email, imaplib
from typing import Optional from typing import Optional
from .mail_parser import parse_message from .mail_parser import parse_message, _safe
IMAP_HOST = "localhost" IMAP_HOST = "localhost"
IMAP_PORT = 993 IMAP_PORT = 993
@ -27,236 +27,259 @@ def _imap_user(u: str) -> str:
return u.split("@")[0] return u.split("@")[0]
async def _connect(username: str, password: str) -> aioimaplib.IMAP4_SSL: def _sync_connect(username: str, password: str) -> imaplib.IMAP4_SSL:
imap = aioimaplib.IMAP4_SSL(host=IMAP_HOST, port=IMAP_PORT, ssl_context=_ssl_ctx()) """동기 imaplib 연결 (aioimaplib 파싱 문제 회피)"""
await imap.wait_hello_from_server() M = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=_ssl_ctx())
res, _ = await imap.login(_imap_user(username), password) M.login(_imap_user(username), password)
if res != "OK": return M
raise ValueError("IMAP 인증 실패")
return imap
async def list_folders(username: str, password: str) -> list[dict]: async def list_folders(username: str, password: str) -> list[dict]:
import re as _re import re as _re
imap = await _connect(username, password)
def _do():
M = _sync_connect(username, password)
try: try:
res, lines = await imap.list('""', '*') res, lines = M.list()
folders = [] folders = []
seen: set = set() seen: set = set()
DEFAULT = ["Sent", "Drafts", "Trash", "Junk"]
for line in lines: for line in (lines or []):
if not line:
continue
raw = line.decode('utf-8','replace') if isinstance(line, bytes) else str(line) raw = line.decode('utf-8','replace') if isinstance(line, bytes) else str(line)
# IMAP LIST 형식: (\Flags) "sep" FolderName # (\HasNoChildren) "." INBOX
m = _re.match(r'\s*\([^)]*\)\s+"[^"]+"\s+"?([^"\s]+)"?\s*$', raw.strip()) m = _re.match(r'\s*\([^)]*\)\s+"[./]"\s+"?([^"]+)"?\s*$', raw.strip())
if not m: if not m:
# 대안: 마지막 따옴표 없는 토큰 continue
m2 = _re.search(r'\)\s+"\."\s+(\S+)$', raw) name = m.group(1).strip().strip('"')
name = m2.group(1).strip() if m2 else ''
else:
name = m.group(1).strip()
# 유효하지 않은 이름 필터
if not name or name in seen: if not name or name in seen:
continue continue
if not _re.match(r'^[\w\-. /]+$', name): if not _re.match(r'^[\w\-. /]+$', name):
continue continue
seen.add(name) seen.add(name)
unread = total = 0 unread = total = 0
try: try:
r2, data = await imap.status(f'"{name}"', '(UNSEEN MESSAGES)') r2, d2 = M.status(f'"{name}"', '(UNSEEN MESSAGES)')
if r2 == "OK" and data: if r2 == "OK" and d2:
s = data[0].decode('utf-8','replace') if isinstance(data[0], bytes) else str(data[0]) s = d2[0].decode('utf-8','replace') if isinstance(d2[0], bytes) else str(d2[0])
mu = _re.search(r'UNSEEN (\d+)', s) mu = _re.search(r'UNSEEN (\d+)', s)
mt = _re.search(r'MESSAGES (\d+)', s) mt = _re.search(r'MESSAGES (\d+)', s)
if mu: unread = int(mu.group(1)) if mu: unread = int(mu.group(1))
if mt: total = int(mt.group(1)) if mt: total = int(mt.group(1))
except Exception: except Exception:
pass pass
folders.append({"name": name, "display": FOLDER_MAP.get(name, name),
"unread": unread, "total": total})
folders.append({ # 기본 폴더 없으면 생성
"name": name, existing = {f["name"] for f in folders}
"display": FOLDER_MAP.get(name, name), for d in DEFAULT:
"unread": unread, if d not in existing:
"total": total, try: M.create(d)
}) except Exception: pass
folders.append({"name": d, "display": FOLDER_MAP[d], "unread": 0, "total": 0})
# 기본 폴더가 없으면 생성 후 목록에 추가
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): def sort_key(f):
try: return FOLDER_ORDER.index(f["name"]) try: return FOLDER_ORDER.index(f["name"])
except ValueError: return 99 except ValueError: return 99
return sorted(folders, key=sort_key) return sorted(folders, key=sort_key)
finally: finally:
try: await imap.logout() try: M.logout()
except Exception: pass except Exception: pass
return await asyncio.get_event_loop().run_in_executor(None, _do)
async def list_messages(username: str, password: str, folder: str = "INBOX", async def list_messages(username: str, password: str, folder: str = "INBOX",
page: int = 1, per_page: int = 50, page: int = 1, per_page: int = 50,
search: Optional[str] = None) -> dict: search: Optional[str] = None) -> dict:
imap = await _connect(username, password) import re as _re
def _do():
M = _sync_connect(username, password)
try: try:
res, _ = await imap.select(f'"{folder}"') res, _ = M.select(f'"{folder}"')
if res != "OK": if res != "OK":
return {"messages": [], "total": 0, "page": page, "per_page": per_page} return {"messages": [], "total": 0, "page": page, "per_page": per_page}
criteria = f'TEXT "{search}"' if search else "ALL" criteria = ['TEXT', search] if search else ['ALL']
res, data = await imap.search(criteria) res2, data2 = M.search(None, *criteria)
if res != "OK" or not data or not data[0]: if res2 != "OK" or not data2 or not data2[0]:
return {"messages": [], "total": 0, "page": page, "per_page": per_page} return {"messages": [], "total": 0, "page": page, "per_page": per_page}
raw = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) all_ids = data2[0].split()
uids = [u for u in raw.split() if u.strip()] total = len(all_ids)
total = len(uids) # 최신순 (역순)
# 최신순 정렬 all_ids = list(reversed(all_ids))
uids = list(reversed(uids))
start = (page - 1) * per_page start = (page - 1) * per_page
page_uids = uids[start: start + per_page] page_ids = all_ids[start: start + per_page]
if not page_uids: if not page_ids:
return {"messages": [], "total": total, "page": page, "per_page": per_page} 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 = [] messages = []
i = 0 for uid_b in page_ids:
while i < len(fetch_data): uid = uid_b.decode() if isinstance(uid_b, bytes) else str(uid_b)
line = fetch_data[i] try:
if not isinstance(line, (bytes, str)) or not str(line).strip(): # FLAGS
i += 1; continue rf, df = M.fetch(uid, '(FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO DATE)])')
s = line.decode() if isinstance(line, bytes) else str(line) if rf != "OK" or not df:
if 'FETCH' not in s and s.strip() not in (')', ''): continue
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
# 헤더 flags_str = ""
header_raw = b"" header_raw = b""
if i + 1 < len(fetch_data) and isinstance(fetch_data[i + 1], bytes): size = 0
header_raw = fetch_data[i + 1]; i += 1
msg = email.message_from_bytes(header_raw) if header_raw else None for item in df:
parsed = parse_message(msg) if msg else {} if isinstance(item, tuple):
meta = item[0].decode('utf-8','replace') if isinstance(item[0], bytes) else str(item[0])
header_raw = item[1] if isinstance(item[1], bytes) else b""
fm = _re.search(r'RFC822\.SIZE (\d+)', meta)
if fm: size = int(fm.group(1))
flags_str = meta
elif isinstance(item, bytes):
s = item.decode('utf-8','replace')
if 'FLAGS' in s: flags_str = s
flags = _re.findall(r'\\(\w+)', flags_str)
is_read = 'Seen' in flags
parsed = {}
if header_raw:
try:
msg = email.message_from_bytes(header_raw)
parsed = parse_message(msg)
except Exception:
pass
messages.append({ messages.append({
"uid": uid, "uid": uid,
"subject": parsed.get("subject", "(제목 없음)"), "subject": _safe(parsed.get("subject") or "(제목 없음)"),
"sender": parsed.get("sender", ""), "sender": _safe(parsed.get("sender") or ""),
"sender_addr": parsed.get("sender_addr", ""), "sender_addr": _safe(parsed.get("sender_addr") or ""),
"date": parsed.get("date", ""), "to": _safe(parsed.get("to") or ""),
"date": _safe(parsed.get("date") or ""),
"is_read": is_read, "is_read": is_read,
"has_attachment": False, "has_attachment": bool(parsed.get("attachments")),
"size": size, "size": size,
"preview": parsed.get("preview", ""), "preview": _safe(parsed.get("preview") or ""),
}) })
i += 1 except Exception:
continue
return {"messages": messages, "total": total, "page": page, "per_page": per_page} return {"messages": messages, "total": total, "page": page, "per_page": per_page}
finally: finally:
try: await imap.logout() try: M.logout()
except Exception: pass except Exception: pass
return await asyncio.get_event_loop().run_in_executor(None, _do)
async def get_message(username: str, password: str, uid: str, folder: str = "INBOX") -> dict: async def get_message(username: str, password: str, uid: str, folder: str = "INBOX") -> dict:
imap = await _connect(username, password) def _do():
M = _sync_connect(username, password)
try: try:
await imap.select(f'"{folder}"') M.select(f'"{folder}"')
# 읽음 처리 M.store(uid, '+FLAGS', '(\\Seen)')
await imap.store(uid, '+FLAGS', '(\\Seen)') res, data = M.fetch(uid, '(RFC822 FLAGS)')
res, data = await imap.fetch(uid, "(UID FLAGS RFC822)")
if res != "OK" or not data: if res != "OK" or not data:
raise ValueError("메일을 찾을 수 없습니다") raise ValueError("메일을 찾을 수 없습니다")
raw = b"" raw = b""
flags_str = ""
for item in data: for item in data:
if isinstance(item, bytes) and len(item) > 200: if isinstance(item, tuple):
raw = item; break raw = item[1] if isinstance(item[1], bytes) else b""
flags_str = item[0].decode('utf-8','replace') if isinstance(item[0], bytes) else ""
if not raw:
raise ValueError("메일 내용을 읽을 수 없습니다")
import re as _re
flags = _re.findall(r'\\(\w+)', flags_str)
msg = email.message_from_bytes(raw) msg = email.message_from_bytes(raw)
parsed = parse_message(msg) 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["uid"] = uid
parsed["is_read"] = "Seen" in flags parsed["is_read"] = "Seen" in flags
return parsed return parsed
finally: finally:
try: await imap.logout() try: M.logout()
except Exception: pass except Exception: pass
return await asyncio.get_event_loop().run_in_executor(None, _do)
async def mark_read(username: str, password: str, uid: str, folder: str, read: bool = True): async def mark_read(username: str, password: str, uid: str, folder: str, read: bool = True):
imap = await _connect(username, password) def _do():
M = _sync_connect(username, password)
try: try:
await imap.select(f'"{folder}"') M.select(f'"{folder}"')
flag = '+FLAGS' if read else '-FLAGS' flag = '+FLAGS' if read else '-FLAGS'
await imap.store(uid, flag, '(\\Seen)') M.store(uid, flag, '(\\Seen)')
finally: finally:
try: await imap.logout() try: M.logout()
except Exception: pass except Exception: pass
await asyncio.get_event_loop().run_in_executor(None, _do)
async def move_message(username: str, password: str, uid: str, src: str, dst: str): async def move_message(username: str, password: str, uid: str, src: str, dst: str):
imap = await _connect(username, password) def _do():
M = _sync_connect(username, password)
try: try:
await imap.select(f'"{src}"') M.select(f'"{src}"')
await imap.copy(uid, f'"{dst}"') M.copy(uid, f'"{dst}"')
await imap.store(uid, '+FLAGS', '(\\Deleted)') M.store(uid, '+FLAGS', '(\\Deleted)')
await imap.expunge() M.expunge()
finally: finally:
try: await imap.logout() try: M.logout()
except Exception: pass except Exception: pass
await asyncio.get_event_loop().run_in_executor(None, _do)
async def delete_message(username: str, password: str, uid: str, folder: str): async def delete_message(username: str, password: str, uid: str, folder: str):
trash = "Trash" trash = "Trash"
if folder == trash: if folder == trash:
# 영구 삭제 def _do():
imap = await _connect(username, password) M = _sync_connect(username, password)
try: try:
await imap.select(f'"{folder}"') M.select(f'"{folder}"')
await imap.store(uid, '+FLAGS', '(\\Deleted)') M.store(uid, '+FLAGS', '(\\Deleted)')
await imap.expunge() M.expunge()
finally: finally:
try: await imap.logout() try: M.logout()
except Exception: pass except Exception: pass
await asyncio.get_event_loop().run_in_executor(None, _do)
else: else:
await move_message(username, password, uid, folder, trash) 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]: async def append_to_sent(username: str, password: str, raw_message: bytes):
imap = await _connect(username, password) """발송된 메일을 Sent 폴더에 IMAP APPEND"""
def _do():
M = _sync_connect(username, password)
try: try:
await imap.select(f'"{folder}"') M.append("Sent", '(\\Seen)', None, raw_message)
res, data = await imap.fetch(uid, "(RFC822)") except Exception:
pass # Sent 저장 실패는 무시 (발송은 이미 성공)
finally:
try: M.logout()
except Exception: pass
await asyncio.get_event_loop().run_in_executor(None, _do)
async def get_attachment(username: str, password: str, uid: str, part_id: str, folder: str) -> tuple:
def _do():
M = _sync_connect(username, password)
try:
M.select(f'"{folder}"')
res, data = M.fetch(uid, '(RFC822)')
raw = b"" raw = b""
for item in data: for item in data:
if isinstance(item, bytes) and len(item) > 200: if isinstance(item, tuple): raw = item[1]; break
raw = item; break
msg = email.message_from_bytes(raw) msg = email.message_from_bytes(raw)
parsed = parse_message(msg) parsed = parse_message(msg)
att_data = parsed["_attachments_data"].get(part_id) att_data = parsed["_attachments_data"].get(part_id)
# 첨부파일 정보
for att in parsed["attachments"]: for att in parsed["attachments"]:
if att["part_id"] == part_id: if att["part_id"] == part_id:
return att_data or b"", att["content_type"], att["filename"] return att_data or b"", att["content_type"], att["filename"]
raise ValueError("첨부파일을 찾을 수 없습니다") raise ValueError("첨부파일을 찾을 수 없습니다")
finally: finally:
try: await imap.logout() try: M.logout()
except Exception: pass except Exception: pass
return await asyncio.get_event_loop().run_in_executor(None, _do)

View File

@ -4,6 +4,14 @@ import chardet, re
from typing import Optional from typing import Optional
def _safe(s: str) -> str:
"""surrogate 문자 제거 → JSON 직렬화 안전"""
try:
return s.encode('utf-8', errors='replace').decode('utf-8', errors='replace')
except Exception:
return ""
def decode_str(raw: Optional[str]) -> str: def decode_str(raw: Optional[str]) -> str:
if not raw: if not raw:
return "" return ""
@ -16,9 +24,9 @@ def decode_str(raw: Optional[str]) -> str:
result.append(part.decode(cs, errors='replace')) result.append(part.decode(cs, errors='replace'))
else: else:
result.append(str(part)) result.append(str(part))
return "".join(result).strip() return _safe("".join(result).strip())
except Exception: except Exception:
return raw or "" return _safe(raw) if raw else ""
def extract_addr(raw: str) -> tuple[str, str]: def extract_addr(raw: str) -> tuple[str, str]:
@ -35,7 +43,7 @@ def decode_payload(part) -> str:
if not charset: if not charset:
detected = chardet.detect(raw) detected = chardet.detect(raw)
charset = detected.get('encoding') or 'utf-8' charset = detected.get('encoding') or 'utf-8'
return raw.decode(charset, errors='replace') return _safe(raw.decode(charset, errors='replace'))
def sanitize_html(html: str) -> str: def sanitize_html(html: str) -> str:

View File

@ -6,7 +6,7 @@ import io
from .auth import verify_imap, create_token, current_user from .auth import verify_imap, create_token, current_user
from .imap_client import ( from .imap_client import (
list_folders, list_messages, get_message, list_folders, list_messages, get_message,
mark_read, move_message, delete_message, get_attachment, mark_read, move_message, delete_message, get_attachment, append_to_sent,
) )
from .smtp_client import send_mail from .smtp_client import send_mail
from .models import ( from .models import (
@ -138,11 +138,13 @@ async def attachment(
@app.post("/api/mail/send") @app.post("/api/mail/send")
async def send(req: SendRequest, user=Depends(current_user)): async def send(req: SendRequest, user=Depends(current_user)):
username, password = user username, password = user
await send_mail( raw_bytes = await send_mail(
username, password, username, password,
req.to, req.subject, req.body, req.to, req.subject, req.body,
req.cc, req.bcc, req.is_html, req.cc, req.bcc, req.is_html,
) )
# 발송 성공 후 Sent 폴더에 저장
await append_to_sent(username, password, raw_bytes)
return {"ok": True} return {"ok": True}

View File

@ -1,9 +1,10 @@
"""SMTP 발송: aiosmtplib + STARTTLS""" """SMTP 발송: smtplib STARTTLS + Sent 폴더 자동 저장"""
import aiosmtplib, ssl import smtplib, ssl, asyncio
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email import encoders from email import encoders
from email.utils import formatdate
from typing import Optional from typing import Optional
SMTP_HOST = "localhost" SMTP_HOST = "localhost"
@ -16,17 +17,16 @@ async def send_mail(
cc: Optional[str] = None, bcc: Optional[str] = None, cc: Optional[str] = None, bcc: Optional[str] = None,
is_html: bool = False, is_html: bool = False,
attachments: Optional[list] = None, attachments: Optional[list] = None,
) -> bool: ) -> bytes:
"""발송 성공 시 raw 메시지 바이트 반환 (Sent 폴더 저장용)"""
msg = MIMEMultipart("alternative" if is_html else "mixed") msg = MIMEMultipart("alternative" if is_html else "mixed")
msg["From"] = username msg["From"] = username
msg["To"] = to msg["To"] = to
msg["Subject"] = subject msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
if cc: msg["Cc"] = cc if cc: msg["Cc"] = cc
if is_html: msg.attach(MIMEText(body, "html" if is_html else "plain", "utf-8"))
msg.attach(MIMEText(body, "html", "utf-8"))
else:
msg.attach(MIMEText(body, "plain", "utf-8"))
if attachments: if attachments:
for name, data, ctype in attachments: for name, data, ctype in attachments:
@ -40,14 +40,20 @@ async def send_mail(
if cc: recipients += [r.strip() for r in cc.split(",")] if cc: recipients += [r.strip() for r in cc.split(",")]
if bcc: recipients += [r.strip() for r in bcc.split(",")] if bcc: recipients += [r.strip() for r in bcc.split(",")]
ssl_ctx = ssl.create_default_context() raw_bytes = msg.as_bytes()
ssl_ctx.check_hostname = False user_short = username.split("@")[0]
ssl_ctx.verify_mode = ssl.CERT_NONE
smtp = aiosmtplib.SMTP(hostname=SMTP_HOST, port=SMTP_PORT, def _do():
start_tls=True, tls_context=ssl_ctx) ctx = ssl.create_default_context()
await smtp.connect() ctx.check_hostname = False
await smtp.login(username.split("@")[0], password) # Postfix SASL: username only ctx.verify_mode = ssl.CERT_NONE
await smtp.sendmail(username, recipients, msg.as_string()) s = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15)
await smtp.quit() s.ehlo()
return True s.starttls(context=ctx)
s.ehlo()
s.login(user_short, password)
s.sendmail(username, recipients, raw_bytes)
s.quit()
await asyncio.get_event_loop().run_in_executor(None, _do)
return raw_bytes

View File

@ -24,6 +24,9 @@ export default function MailList({ onRefresh }: Props) {
selectedMail, setSelectedMail, setLoading, setMessages, selectedMail, setSelectedMail, setLoading, setMessages,
markRead, removeMessage } = useMailStore() markRead, removeMessage } = useMailStore()
// Sent 폴더에서는 받는사람 표시
const isSent = currentFolder === 'Sent'
const select = async (uid: string) => { const select = async (uid: string) => {
setLoading(true) setLoading(true)
try { try {
@ -76,7 +79,9 @@ export default function MailList({ onRefresh }: Props) {
onClick={() => select(m.uid)} onClick={() => select(m.uid)}
> >
<div className="mail-item-top"> <div className="mail-item-top">
<span className="mail-sender">{m.sender || m.sender_addr}</span> <span className="mail-sender">
{isSent ? `${(m as any).to || m.sender_addr}` : (m.sender || m.sender_addr)}
</span>
<span className="mail-date">{fmtDate(m.date)}</span> <span className="mail-date">{fmtDate(m.date)}</span>
</div> </div>
<div className="mail-item-subject">{m.subject}</div> <div className="mail-item-subject">{m.subject}</div>