diff --git a/nanoclaude/services/job_manager.py b/nanoclaude/services/job_manager.py index 3ff8609..4795878 100644 --- a/nanoclaude/services/job_manager.py +++ b/nanoclaude/services/job_manager.py @@ -21,6 +21,7 @@ class Job: rewritten_message: str = "" callback: str = "" # "synology" | "" callback_meta: dict = field(default_factory=dict) # username, user_id 등 + response_sent: bool = False # Synology에 응답 전송 여부 class JobManager: diff --git a/nanoclaude/services/worker.py b/nanoclaude/services/worker.py index cf1198a..21be80d 100644 --- a/nanoclaude/services/worker.py +++ b/nanoclaude/services/worker.py @@ -88,11 +88,12 @@ def _parse_classification(raw: str) -> dict: async def _send_callback(job: Job, text: str) -> None: - """Synology callback이면 전송.""" + """Synology callback이면 전송 + response_sent 플래그.""" if job.callback == "synology": if len(text) > SYNOLOGY_MAX_LEN: text = text[:SYNOLOGY_MAX_LEN] + "\n\n...(생략됨)" await send_to_synology(text) + job.response_sent = True def _pre_route(message: str) -> dict | None: @@ -387,6 +388,7 @@ async def run(job: Job) -> None: if job.callback == "synology": try: await send_to_synology("⚠️ 처리 중 오류가 발생했습니다. 다시 시도해주세요.", raw=True) + job.response_sent = True except Exception: pass try: @@ -395,3 +397,9 @@ async def run(job: Job) -> None: pass finally: 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 diff --git a/nanoclaude/tools/email_tool.py b/nanoclaude/tools/email_tool.py index 1886267..75230ca 100644 --- a/nanoclaude/tools/email_tool.py +++ b/nanoclaude/tools/email_tool.py @@ -1,12 +1,13 @@ -"""Email 도구 — IMAP을 통한 메일 조회 (read-only).""" +"""Email 도구 — IMAP을 통한 메일 조회 (read-only, 멀티 폴더).""" from __future__ import annotations import email import email.header +import email.utils import imaplib import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from config import settings @@ -15,6 +16,13 @@ logger = logging.getLogger(__name__) TOOL_NAME = "email" MAX_RESULTS = 10 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: @@ -32,17 +40,23 @@ def _decode_header(raw: str) -> str: 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: """메일 본문 추출.""" if msg.is_multipart(): for part in msg.walk(): - ct = part.get_content_type() - if ct == "text/plain": + if part.get_content_type() == "text/plain": payload = part.get_payload(decode=True) if payload: charset = part.get_content_charset() or "utf-8" return payload.decode(charset, errors="replace") - # fallback to html for part in msg.walk(): if part.get_content_type() == "text/html": payload = part.get_payload(decode=True) @@ -66,51 +80,72 @@ def _connect(): async def search(query: str = "", days: int = 7) -> dict: - """최근 메일 검색.""" + """전체 폴더에서 최근 메일 검색.""" try: conn = _connect() if not conn: return _make_result(False, "search", error="메일 설정이 없습니다.") - conn.select("INBOX", readonly=True) - since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y") if query: criteria = f'(SINCE {since} SUBJECT "{query}")' else: criteria = f"(SINCE {since})" - _, data = conn.search(None, criteria) - uids = data[0].split() + all_results = [] + failed_folders = [] - # 최신 MAX_RESULTS개만 - uids = uids[-MAX_RESULTS:] - uids.reverse() + for folder in FOLDERS: + try: + conn.select(folder, readonly=True) + _, data = conn.search(None, criteria) + uids = data[0].split() + uids = uids[-MAX_RESULTS:] # 폴더당 최대 - results = [] - for uid in uids: - _, msg_data = conn.fetch(uid, "(RFC822.HEADER)") - if not msg_data or not msg_data[0]: + for uid in uids: + try: + _, msg_data = conn.fetch(uid, "(RFC822.HEADER)") + 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 - 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() - 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: logger.exception("Email search failed") @@ -118,19 +153,25 @@ async def search(query: str = "", days: int = 7) -> dict: async def read(uid: str) -> dict: - """특정 메일 본문 조회.""" + """특정 메일 본문 조회. uid 형식: folder:uid""" try: conn = _connect() if not conn: return _make_result(False, "read", error="메일 설정이 없습니다.") - conn.select("INBOX", readonly=True) - _, msg_data = conn.fetch(uid.encode(), "(RFC822)") + # folder:uid 파싱 + 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]: conn.close() conn.logout() - return _make_result(False, "read", error=f"UID {uid} 메일을 찾을 수 없습니다.") + return _make_result(False, "read", error=f"메일을 찾을 수 없습니다.") raw = msg_data[0][1] msg = email.message_from_bytes(raw) @@ -138,7 +179,7 @@ async def read(uid: str) -> dict: subject = _decode_header(msg.get("Subject", "")) from_addr = _decode_header(msg.get("From", "")) date_str = msg.get("Date", "") - body = _get_body(msg)[:PREVIEW_LEN * 5] # read는 더 긴 본문 + body = _get_body(msg)[:PREVIEW_LEN * 5] conn.close() conn.logout()