feat(nextcloud): Nextcloud 4모듈 + NC핸들러 + AI Foreman v0.1

- tools/nextcloud_client.py: WebDAV/OCS/CalDAV/CardDAV 공통 클라이언트
- tools/nc_files.py: 파일 검색/목록/최근/공유링크
- tools/nc_calendar.py: CalDAV 일정 CRUD + ICS 빌더
- tools/nc_mail.py: IMAP 메일 조회 (PLAIN auth for Mailcow)
- tools/nc_contacts.py: CardDAV 연락처 + EasyOCR 명함 스캔
- handlers/nc_handler.py: 자연어→NC도구 자동 라우팅
- core/foreman.py: 목표 분해 + 상담 세션 + Vikunja 등록
- prompts/foreman.md: Foreman 시스템 프롬프트
- prompts/unified.md: nextcloud 모드 분류 추가
- config.py: .env 따옴표 파싱 버그 수정
- api/discord_bot.py: /goal 커맨드 + Foreman 스레드 라우팅
This commit is contained in:
2026-03-18 17:25:27 +09:00
parent aae9c188eb
commit d22493125c
14 changed files with 2709 additions and 2 deletions

324
tools/nc_mail.py Normal file
View File

@@ -0,0 +1,324 @@
r"""Nextcloud Mail (IMAP) 모듈 — 메일 조회/검색/요약.
Mailcow IMAP 직접 연결로 메일 조회.
AI 요약 기능은 GeminiCaller를 통해 처리.
"""
import asyncio
import email
import email.header
import email.message
import imaplib
import logging
import os
import re
import sys
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import config
logger = logging.getLogger("variet.tools.nc_mail")
@dataclass
class MailMessage:
"""메일 메시지 정보."""
uid: str
subject: str
sender: str
date: str
snippet: str = "" # 본문 미리보기 (200자)
body: str = "" # 전체 본문
is_read: bool = False
@property
def sender_name(self) -> str:
"""발신자 이름만 추출."""
if "<" in self.sender:
return self.sender.split("<")[0].strip().strip('"')
return self.sender
@property
def sender_email(self) -> str:
"""발신자 이메일만 추출."""
m = re.search(r"<(.+?)>", self.sender)
return m.group(1) if m else self.sender
def to_display(self) -> str:
"""Discord embed용 표시."""
read_icon = "📬" if not self.is_read else "📭"
return f"{read_icon} **{self.sender_name}** — {self.subject}\n _{self.snippet}_"
class NCMailClient:
"""Mailcow IMAP 메일 클라이언트."""
def __init__(
self,
host: str = None,
port: int = None,
username: str = None,
password: str = None,
):
self.host = host or config.MAIL_IMAP_HOST
self.port = port or config.MAIL_IMAP_PORT
self.username = username or config.MAIL_USER
self.password = password or config.MAIL_PASSWORD
def _connect(self) -> imaplib.IMAP4_SSL:
"""IMAP SSL 연결 + 로그인 (PLAIN auth — Mailcow 앱 비밀번호 호환)."""
conn = imaplib.IMAP4_SSL(self.host, self.port)
# Mailcow 앱 비밀번호는 LOGIN 커맨드 거부 → PLAIN auth 사용
conn.authenticate(
"PLAIN",
lambda x: (
"\0" + self.username + "\0" + self.password
).encode(),
)
return conn
@staticmethod
def _decode_header(raw: str) -> str:
"""MIME 인코딩된 헤더 디코딩."""
if not raw:
return ""
decoded_parts = email.header.decode_header(raw)
result = []
for part, charset in decoded_parts:
if isinstance(part, bytes):
result.append(part.decode(charset or "utf-8", errors="replace"))
else:
result.append(part)
return " ".join(result)
@staticmethod
def _extract_body(msg: email.message.Message, max_len: int = 2000) -> str:
"""메일 본문 추출 (text/plain 우선)."""
body = ""
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
body = payload.decode(charset, errors="replace")
break
# text/plain이 없으면 text/html에서 태그 제거
if not body:
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/html":
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
html = payload.decode(charset, errors="replace")
body = re.sub(r"<[^>]+>", "", html)
body = re.sub(r"\s+", " ", body).strip()
break
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
body = payload.decode(charset, errors="replace")
return body[:max_len]
def _fetch_message(self, conn: imaplib.IMAP4_SSL, uid: str, body: bool = False) -> Optional[MailMessage]:
"""UID로 메일 메시지 가져오기."""
fetch_parts = "(FLAGS BODY.PEEK[HEADER])" if not body else "(FLAGS BODY.PEEK[])"
status, data = conn.uid("FETCH", uid, fetch_parts)
if status != "OK" or not data or data[0] is None:
return None
raw_email = data[0][1] if isinstance(data[0], tuple) else data[0]
if isinstance(raw_email, bytes):
msg = email.message_from_bytes(raw_email)
else:
return None
# FLAGS 파싱
flags_data = data[0][0] if isinstance(data[0], tuple) else b""
is_read = b"\\Seen" in flags_data
subject = self._decode_header(msg.get("Subject", ""))
sender = self._decode_header(msg.get("From", ""))
date_str = msg.get("Date", "")
mail_body = ""
snippet = ""
if body:
mail_body = self._extract_body(msg)
snippet = mail_body[:200].replace("\n", " ").strip()
else:
snippet = subject[:200]
return MailMessage(
uid=uid,
subject=subject,
sender=sender,
date=date_str,
snippet=snippet,
body=mail_body,
is_read=is_read,
)
# ──────────────────────────────────────
# 미확인 메일
# ──────────────────────────────────────
async def get_unread(self, limit: int = 5, mailbox: str = "INBOX") -> list[MailMessage]:
"""미확인 메일 조회.
Args:
limit: 최대 건수
mailbox: 메일함 (기본: INBOX)
"""
def _fetch():
conn = self._connect()
try:
conn.select(mailbox, readonly=True)
status, data = conn.uid("SEARCH", None, "UNSEEN")
if status != "OK":
return []
uids = data[0].split()
# 최신 순
uids = uids[-limit:] if len(uids) > limit else uids
uids.reverse()
messages = []
for uid_bytes in uids:
uid = uid_bytes.decode()
msg = self._fetch_message(conn, uid, body=True)
if msg:
messages.append(msg)
return messages
finally:
conn.logout()
messages = await asyncio.to_thread(_fetch)
logger.info(f"미확인 메일 조회: {len(messages)}")
return messages
# ──────────────────────────────────────
# 메일 검색
# ──────────────────────────────────────
async def search(self, query: str, mailbox: str = "INBOX", limit: int = 10) -> list[MailMessage]:
"""메일 검색.
Args:
query: IMAP SEARCH 쿼리 또는 자연어 키워드
mailbox: 메일함
limit: 최대 건수
"""
def _search():
conn = self._connect()
try:
conn.select(mailbox, readonly=True)
# 자연어 → IMAP SEARCH 변환
if query.upper().startswith(("FROM", "SUBJECT", "SINCE", "BEFORE", "TO")):
search_criteria = query
else:
# 기본: SUBJECT + FROM 동시 검색
search_criteria = f'OR SUBJECT "{query}" FROM "{query}"'
status, data = conn.uid("SEARCH", None, search_criteria)
if status != "OK":
return []
uids = data[0].split()
uids = uids[-limit:] if len(uids) > limit else uids
uids.reverse()
messages = []
for uid_bytes in uids:
uid = uid_bytes.decode()
msg = self._fetch_message(conn, uid, body=False)
if msg:
messages.append(msg)
return messages
finally:
conn.logout()
messages = await asyncio.to_thread(_search)
logger.info(f"메일 검색 '{query}': {len(messages)}")
return messages
# ──────────────────────────────────────
# 메일 본문 조회
# ──────────────────────────────────────
async def get_message(self, uid: str, mailbox: str = "INBOX") -> Optional[MailMessage]:
"""메일 본문 전체 조회.
Args:
uid: 메일 UID
mailbox: 메일함
"""
def _fetch():
conn = self._connect()
try:
conn.select(mailbox, readonly=True)
return self._fetch_message(conn, uid, body=True)
finally:
conn.logout()
return await asyncio.to_thread(_fetch)
# ──────────────────────────────────────
# CLI
# ──────────────────────────────────────
async def _cli():
import io
if sys.stdout.encoding != "utf-8":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
args = sys.argv[1:]
client = NCMailClient()
if not args:
print("사용법:")
print(" nc_mail.py unread [갯수]")
print(' nc_mail.py search "키워드"')
print(" nc_mail.py get <uid>")
return
if args[0] == "unread":
limit = int(args[1]) if len(args) > 1 else 5
messages = await client.get_unread(limit)
print(f"📬 미확인 메일 {len(messages)}건:\n")
for i, m in enumerate(messages, 1):
print(f" {i}. {m.to_display()}")
print(f" 📅 {m.date}")
print()
elif args[0] == "search" and len(args) > 1:
query = " ".join(args[1:])
messages = await client.search(query)
print(f"🔍 '{query}' 검색 결과: {len(messages)}\n")
for i, m in enumerate(messages, 1):
read = "📭" if m.is_read else "📬"
print(f" {i}. {read} [{m.uid}] {m.sender_name}{m.subject}")
print(f" 📅 {m.date}")
elif args[0] == "get" and len(args) > 1:
uid = args[1]
msg = await client.get_message(uid)
if msg:
print(f"📧 {msg.subject}")
print(f" From: {msg.sender}")
print(f" Date: {msg.date}")
print(f"{'' * 40}")
print(msg.body[:1000])
else:
print(f"❌ UID {uid} 메일을 찾을 수 없습니다.")
if __name__ == "__main__":
asyncio.run(_cli())