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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user