34eb5c9411
다이제스트/이메일수집알림/법령알림 메일 발송 폐기 (사용자 결정 2026-06-10). 근거: 게이트(if smtp_host and smtp_user)가 06-07 전엔 항상 false(silent skip), 자격증명 활성 후엔 100% 553 Sender rejected — 한 통도 전달 성공 이력 없음. law_monitor 는 CalDAV VTODO 가 단일 알림 채널로 유지. 다이제스트 .md 생성/ 90일 아카이브, 이메일 IMAP 수집은 무변경. eid dispatch 의 send_smtp_email 문자열 블랙리스트는 의도적 잔존(코드층 박탈 강화와 정합). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
109 lines
3.0 KiB
Python
109 lines
3.0 KiB
Python
"""공통 유틸리티 — v1 pkm_utils.py에서 AppleScript 제거, 나머지 포팅"""
|
|
|
|
import hashlib
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
|
|
def setup_logger(name: str, log_dir: str = "logs") -> logging.Logger:
|
|
"""로거 설정"""
|
|
Path(log_dir).mkdir(exist_ok=True)
|
|
logger = logging.getLogger(name)
|
|
logger.setLevel(logging.INFO)
|
|
|
|
if not logger.handlers:
|
|
# 파일 핸들러
|
|
fh = logging.FileHandler(f"{log_dir}/{name}.log", encoding="utf-8")
|
|
fh.setFormatter(logging.Formatter(
|
|
"%(asctime)s [%(levelname)s] %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S"
|
|
))
|
|
logger.addHandler(fh)
|
|
|
|
# 콘솔 핸들러
|
|
ch = logging.StreamHandler()
|
|
ch.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
|
|
logger.addHandler(ch)
|
|
|
|
return logger
|
|
|
|
|
|
def file_hash(path: str | Path) -> str:
|
|
"""파일 SHA-256 해시 계산"""
|
|
sha256 = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(8192), b""):
|
|
sha256.update(chunk)
|
|
return sha256.hexdigest()
|
|
|
|
|
|
def count_log_errors(log_path: str) -> int:
|
|
"""로그 파일에서 ERROR 건수 카운트"""
|
|
try:
|
|
with open(log_path, encoding="utf-8") as f:
|
|
return sum(1 for line in f if "[ERROR]" in line)
|
|
except FileNotFoundError:
|
|
return 0
|
|
|
|
|
|
# ─── CalDAV 헬퍼 ───
|
|
|
|
|
|
def escape_ical_text(text: str | None) -> str:
|
|
"""iCalendar TEXT 값 이스케이프 (RFC 5545 §3.3.11).
|
|
SUMMARY, DESCRIPTION, LOCATION 등 TEXT 프로퍼티에 사용.
|
|
"""
|
|
if not text:
|
|
return ""
|
|
text = text.replace("\r\n", "\n").replace("\r", "\n") # CRLF 정규화
|
|
text = text.replace("\\", "\\\\") # 백슬래시 먼저
|
|
text = text.replace("\n", "\\n")
|
|
text = text.replace(",", "\\,")
|
|
text = text.replace(";", "\\;")
|
|
return text
|
|
|
|
|
|
def create_caldav_todo(
|
|
caldav_url: str,
|
|
username: str,
|
|
password: str,
|
|
title: str,
|
|
description: str = "",
|
|
due_days: int = 7,
|
|
) -> str | None:
|
|
"""Synology Calendar에 VTODO 생성, UID 반환"""
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import caldav
|
|
|
|
try:
|
|
client = caldav.DAVClient(url=caldav_url, username=username, password=password)
|
|
principal = client.principal()
|
|
calendars = principal.calendars()
|
|
if not calendars:
|
|
return None
|
|
|
|
calendar = calendars[0]
|
|
uid = str(uuid.uuid4())
|
|
due = datetime.now(timezone.utc) + timedelta(days=due_days)
|
|
due_str = due.strftime("%Y%m%dT%H%M%SZ")
|
|
|
|
vtodo = f"""BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
BEGIN:VTODO
|
|
UID:{uid}
|
|
SUMMARY:{escape_ical_text(title)}
|
|
DESCRIPTION:{escape_ical_text(description)}
|
|
DUE:{due_str}
|
|
STATUS:NEEDS-ACTION
|
|
PRIORITY:5
|
|
END:VTODO
|
|
END:VCALENDAR"""
|
|
|
|
calendar.save_event(vtodo)
|
|
return uid
|
|
except Exception as e:
|
|
logging.getLogger("caldav").error(f"CalDAV VTODO 생성 실패: {e}")
|
|
return None
|