fix: 이메일 멀티 폴더 조회 + Synology 무응답 방지

- email_tool: INBOX/Gmail/Technicalkorea 3개 폴더 순회
  - 폴더별 try/except isolation (하나 실패해도 나머지 조회)
  - UID → folder:uid 형식 (폴더간 충돌 방지)
  - 날짜 parsedate_to_datetime 정렬
  - 폴더 라벨 표시 (개인/Gmail/회사)
- worker: response_sent 플래그 + finally 무응답 방지
  - 어떤 에러 경로에서든 Synology에 최소 1회 응답 보장

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-06 14:38:35 +09:00
parent f73c46de3a
commit 12ca8f19d9
3 changed files with 89 additions and 39 deletions

View File

@@ -21,6 +21,7 @@ class Job:
rewritten_message: str = "" rewritten_message: str = ""
callback: str = "" # "synology" | "" callback: str = "" # "synology" | ""
callback_meta: dict = field(default_factory=dict) # username, user_id 등 callback_meta: dict = field(default_factory=dict) # username, user_id 등
response_sent: bool = False # Synology에 응답 전송 여부
class JobManager: class JobManager:

View File

@@ -88,11 +88,12 @@ def _parse_classification(raw: str) -> dict:
async def _send_callback(job: Job, text: str) -> None: async def _send_callback(job: Job, text: str) -> None:
"""Synology callback이면 전송.""" """Synology callback이면 전송 + response_sent 플래그."""
if job.callback == "synology": if job.callback == "synology":
if len(text) > SYNOLOGY_MAX_LEN: if len(text) > SYNOLOGY_MAX_LEN:
text = text[:SYNOLOGY_MAX_LEN] + "\n\n...(생략됨)" text = text[:SYNOLOGY_MAX_LEN] + "\n\n...(생략됨)"
await send_to_synology(text) await send_to_synology(text)
job.response_sent = True
def _pre_route(message: str) -> dict | None: def _pre_route(message: str) -> dict | None:
@@ -387,6 +388,7 @@ async def run(job: Job) -> None:
if job.callback == "synology": if job.callback == "synology":
try: try:
await send_to_synology("⚠️ 처리 중 오류가 발생했습니다. 다시 시도해주세요.", raw=True) await send_to_synology("⚠️ 처리 중 오류가 발생했습니다. 다시 시도해주세요.", raw=True)
job.response_sent = True
except Exception: except Exception:
pass pass
try: try:
@@ -395,3 +397,9 @@ async def run(job: Job) -> None:
pass pass
finally: finally:
await state_stream.push_done(job.id) await state_stream.push_done(job.id)
# Synology 무응답 방지: 응답이 한 번도 안 갔으면 에러 메시지
if job.callback == "synology" and not job.response_sent:
try:
await send_to_synology("⚠️ 요청을 처리하지 못했습니다. 다시 시도해주세요.", raw=True)
except Exception:
pass

View File

@@ -1,12 +1,13 @@
"""Email 도구 — IMAP을 통한 메일 조회 (read-only).""" """Email 도구 — IMAP을 통한 메일 조회 (read-only, 멀티 폴더)."""
from __future__ import annotations from __future__ import annotations
import email import email
import email.header import email.header
import email.utils
import imaplib import imaplib
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from config import settings from config import settings
@@ -15,6 +16,13 @@ logger = logging.getLogger(__name__)
TOOL_NAME = "email" TOOL_NAME = "email"
MAX_RESULTS = 10 MAX_RESULTS = 10
PREVIEW_LEN = 200 PREVIEW_LEN = 200
FOLDERS = ["INBOX", "Gmail", "Technicalkorea"]
FOLDER_LABELS = {
"INBOX": "개인",
"Gmail": "Gmail",
"Technicalkorea": "회사",
}
def _make_result(ok: bool, operation: str, data=None, summary: str = "", error: str | None = None) -> dict: def _make_result(ok: bool, operation: str, data=None, summary: str = "", error: str | None = None) -> dict:
@@ -32,17 +40,23 @@ def _decode_header(raw: str) -> str:
return "".join(decoded) return "".join(decoded)
def _parse_date(date_str: str) -> datetime:
"""메일 Date 헤더 → datetime. 실패 시 epoch."""
try:
return email.utils.parsedate_to_datetime(date_str)
except Exception:
return datetime(1970, 1, 1, tzinfo=timezone.utc)
def _get_body(msg) -> str: def _get_body(msg) -> str:
"""메일 본문 추출.""" """메일 본문 추출."""
if msg.is_multipart(): if msg.is_multipart():
for part in msg.walk(): for part in msg.walk():
ct = part.get_content_type() if part.get_content_type() == "text/plain":
if ct == "text/plain":
payload = part.get_payload(decode=True) payload = part.get_payload(decode=True)
if payload: if payload:
charset = part.get_content_charset() or "utf-8" charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace") return payload.decode(charset, errors="replace")
# fallback to html
for part in msg.walk(): for part in msg.walk():
if part.get_content_type() == "text/html": if part.get_content_type() == "text/html":
payload = part.get_payload(decode=True) payload = part.get_payload(decode=True)
@@ -66,51 +80,72 @@ def _connect():
async def search(query: str = "", days: int = 7) -> dict: async def search(query: str = "", days: int = 7) -> dict:
"""최근 메일 검색.""" """전체 폴더에서 최근 메일 검색."""
try: try:
conn = _connect() conn = _connect()
if not conn: if not conn:
return _make_result(False, "search", error="메일 설정이 없습니다.") return _make_result(False, "search", error="메일 설정이 없습니다.")
conn.select("INBOX", readonly=True)
since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y") since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y")
if query: if query:
criteria = f'(SINCE {since} SUBJECT "{query}")' criteria = f'(SINCE {since} SUBJECT "{query}")'
else: else:
criteria = f"(SINCE {since})" criteria = f"(SINCE {since})"
_, data = conn.search(None, criteria) all_results = []
uids = data[0].split() failed_folders = []
# 최신 MAX_RESULTS개만 for folder in FOLDERS:
uids = uids[-MAX_RESULTS:] try:
uids.reverse() conn.select(folder, readonly=True)
_, data = conn.search(None, criteria)
uids = data[0].split()
uids = uids[-MAX_RESULTS:] # 폴더당 최대
results = [] for uid in uids:
for uid in uids: try:
_, msg_data = conn.fetch(uid, "(RFC822.HEADER)") _, msg_data = conn.fetch(uid, "(RFC822.HEADER)")
if not msg_data or not msg_data[0]: if not msg_data or not msg_data[0]:
continue
raw = msg_data[0][1]
msg = email.message_from_bytes(raw)
subject = _decode_header(msg.get("Subject", "(제목 없음)"))
from_addr = _decode_header(msg.get("From", ""))
date_str = msg.get("Date", "")
dt = _parse_date(date_str)
label = FOLDER_LABELS.get(folder, folder)
all_results.append({
"uid": f"{folder}:{uid.decode()}",
"folder": label,
"subject": subject,
"from": from_addr,
"date": dt.strftime("%m/%d %H:%M"),
"_sort_key": dt.timestamp(),
})
except Exception:
continue
conn.close()
except Exception as e:
logger.warning("Email folder %s failed: %s", folder, e)
failed_folders.append(folder)
continue continue
raw = msg_data[0][1]
msg = email.message_from_bytes(raw)
subject = _decode_header(msg.get("Subject", "(제목 없음)"))
from_addr = _decode_header(msg.get("From", ""))
date_str = msg.get("Date", "")
results.append({
"uid": uid.decode(),
"subject": subject,
"from": from_addr,
"date": date_str[:20],
})
conn.close()
conn.logout() conn.logout()
summary = f"최근 {days}일간 {len(results)}개의 메일이 있습니다." # 날짜순 정렬 (최신 먼저) + 제한
return _make_result(True, "search", data=results, summary=summary) all_results.sort(key=lambda x: x.get("_sort_key", 0), reverse=True)
all_results = all_results[:MAX_RESULTS]
for r in all_results:
r.pop("_sort_key", None)
summary = f"최근 {days}일간 {len(all_results)}개의 메일이 있습니다."
if failed_folders:
summary += f" (일부 폴더 조회 실패: {', '.join(failed_folders)})"
return _make_result(True, "search", data=all_results, summary=summary)
except Exception as e: except Exception as e:
logger.exception("Email search failed") logger.exception("Email search failed")
@@ -118,19 +153,25 @@ async def search(query: str = "", days: int = 7) -> dict:
async def read(uid: str) -> dict: async def read(uid: str) -> dict:
"""특정 메일 본문 조회.""" """특정 메일 본문 조회. uid 형식: folder:uid"""
try: try:
conn = _connect() conn = _connect()
if not conn: if not conn:
return _make_result(False, "read", error="메일 설정이 없습니다.") return _make_result(False, "read", error="메일 설정이 없습니다.")
conn.select("INBOX", readonly=True) # folder:uid 파싱
_, msg_data = conn.fetch(uid.encode(), "(RFC822)") if ":" in uid:
folder, imap_uid = uid.split(":", 1)
else:
folder, imap_uid = "INBOX", uid
conn.select(folder, readonly=True)
_, msg_data = conn.fetch(imap_uid.encode(), "(RFC822)")
if not msg_data or not msg_data[0]: if not msg_data or not msg_data[0]:
conn.close() conn.close()
conn.logout() conn.logout()
return _make_result(False, "read", error=f"UID {uid} 메일을 찾을 수 없습니다.") return _make_result(False, "read", error=f"메일을 찾을 수 없습니다.")
raw = msg_data[0][1] raw = msg_data[0][1]
msg = email.message_from_bytes(raw) msg = email.message_from_bytes(raw)
@@ -138,7 +179,7 @@ async def read(uid: str) -> dict:
subject = _decode_header(msg.get("Subject", "")) subject = _decode_header(msg.get("Subject", ""))
from_addr = _decode_header(msg.get("From", "")) from_addr = _decode_header(msg.get("From", ""))
date_str = msg.get("Date", "") date_str = msg.get("Date", "")
body = _get_body(msg)[:PREVIEW_LEN * 5] # read는 더 긴 본문 body = _get_body(msg)[:PREVIEW_LEN * 5]
conn.close() conn.close()
conn.logout() conn.logout()